first commit

This commit is contained in:
hdvt
2026-03-28 16:48:16 +00:00
commit 0c097ebf97
15478 changed files with 850272 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>About</title>
<link href="iframe.css" rel="stylesheet">
</head>
<body>
<nav>
<a href="#changelog">Changelog</a>
<a href="#credits">Credits</a>
</nav>
<article class="about">
<h2 id="changelog">Changelog</h2>
<dl>
<dt>9.2</dt>
<dd>Fixed an error when saving incomplete proxies (#197)</dd>
<dt>9.1</dt>
<dd>Fixed an error on Firefox for Android (#196)</dd>
<dt>9.0</dt>
<dd>Added "407 Proxy Authentication Required" to the log (#145)</dd>
<dd>Added "Get Associated Domains" feature to Log (#191)</dd>
<dd>Added "Get Associated Domains" option to add patterns</dd>
<dd>Added Bulk Edit user interface feature (#131, #137, #169)</dd>
<dd>Added Context Menu feature</dd>
<dd>Added Ctrl-Click option to Add Pattern (#169)</dd>
<dd>Added Ctrl-Click option to Add Proxy</dd>
<dd>Added extra options to Import FoxyProxy Account</dd>
<dd>Added link to Open Shortcut Settings link (Firefox 137)</dd>
<dd>Added links to Help from Options</dd>
<dd>Added Match Pattern support (#177)</dd>
<dd>Added Open Link in New Tab Proxy feature (#149)</dd>
<dd>Added Ping feature</dd>
<dd>Added Proxy by Pattern tester</dd>
<dd>Added show/hide feature to toolbar popup elements based on Tab URL</dd>
<dd>Added Tab Proxy by patterns feature (Firefox only) (#180)</dd>
<dd>Added Test feature</dd>
<dd>Changed Firefox icons to PNG for compatibility with Chrome</dd>
<dd>Enabled sending username without password for SOCKS (Firefox only)</dd>
<dd>Fixed a Drag and Drop issue (#183)</dd>
<dd>Fixed an issue with resetting containers (from 8.10) (#185)</dd>
<dd>Increased minimum version to Firefox 128 (released 2024-07-09) due to Firefox root certificate expiry on 2025-03-14</dd>
<dd>Removed Chrome v3 to v8 data migration decryption code</dd>
<dd>Removed default network passthrough on Firefox in favour of <code>about:config</code> setting (Firefox 137+) (#178)</dd>
<dd>Renamed "Quick Add" to "Include Host"</dd>
<dd>Repurposed "Exclude Host" to match "Include Host" function</dd>
<dd>Unified Firefox and Chrome "manifest.json"</dd>
<dd>Updated browser badge code to reflect Global Exclude</dd>
<dd>Updated data migration for Match Pattern use (#177)</dd>
<dd>Updated Firefox manifest to MV3</dd>
<dd>Updated Include/Exclude Host to generate Glob patterns</dd>
<dd>Updated Limit WebRTC options (#162)</dd>
<dd>Updated Options page user interface</dd>
<dd>Updated Pattern Tester</dd>
<dd>Updated Tab Proxy for MV3</dd>
<dd>Updated toolbar popup user interface</dd>
<!--
https://blog.mozilla.org/addons/2025/03/10/root-certificate-will-expire-on-14-march-users-need-to-update-firefox-to-prevent-add-on-breakage/
https://blog.mozilla.org/addons/2024/07/10/manifest-v3-updates-landed-in-firefox-128/
minimum version to Firefox 128:
-----
FF109, Ch88: "host_permissions": Property "host_permissions" is unsupported in Manifest Version 2
FF112, Ch92: Added Background script module, removed "content/background.html"
FF115: Deprecate browser_style in MV3 defaults to false
FF115, Ch102: storage.session 10mb (Ch102-Ch114 1mb)
FF121, Ch105: :has()
FF126, Ch108: webRequestAuthProvider
FF117, Ch112 (2023-04-04): CSS nesting ?
-->
<dt>8.10</dt>
<dd>Added console log for Save file errors (#144)</dd>
<dd>Added custom container option (#33, #161)</dd>
<dd>Added direct HTTP authentication (Firefox 125+)</dd>
<dd>Added folder to Auto Backup (#156)</dd>
<dd>Added new options to the proxy types</dd>
<dd>Added QUIC (HTTP) option (Chrome only) (experimental) (#124)</dd>
<dd>Removed a bug that caused Glob wildcard to match domains with subdomains</dd>
<dd>Updated browser detection (firefox-extension/issues/220, #139, #141)</dd>
<dd>Updated default network passthrough on Firefox to include <code>localhost, 127.0.0.1, ::1</code> (firefox-extension/issues/159, #20, #134)</dd>
<dd>Updated managed storage to allow Tab Proxy (#172)</dd>
<dd>Updated Options page user interface</dd>
<dd>Updated Tab Proxy to include new tab (#157)</dd>
<dd>Updated toolbar popup user interface</dd>
<dt>8.9</dt>
<dd>Added "Log" to the toolbar popup buttons (#44)</dd>
<dd>Added limited log display on Chrome (experimental)</dd>
<dd>Added Theme feature (#71, #100)</dd>
<dd>Added toggle more options on toolbar popup (#54)</dd>
<dd>Fixed proxy DNS in "Import Proxy List" (#102) (from 8.7)</dd>
<dd>Fixed settings upgrade (import older) when hostname is missing (#108)</dd>
<dd>Fixed settings upgrade (import older) when username/password is missing (#103)</dd>
<dd>Fixed socks in "Import Proxy List" on some Firefox (#120)</dd>
<dd>Increased log content</dd>
<dd>Removed "Show Pattern Proxy" option and made it default (#57) (from 8.7) (Firefox only)</dd>
<dd>Removed Tab Proxy page-action and set it to the toolbar icon (#114) (Firefox only)</dd>
<dd>Updated add pattern user interface (#105)</dd>
<dd>Updated code to process duplicate hostname:port (#33, #76)</dd>
<dd>Updated options to disable "Store Locally" on Firefox (Chrome only)</dd>
<dd>Updated toolbar popup include/exclude host feature</dd>
<dd>Updated toolbar popup user interface</dd>
<dt>8.8</dt>
<dd>Added Show hidden feature</dd>
<dd>Fixed an issue with sync (#99)</dd>
<dd>Updated code to process duplicate hostname:port (#33, #76)</dd>
<dd>Updated user interface to hide patterns on FoxyProxy Basic</dd>
<dt>8.7</dt>
<dd>Added Auto Backup feature</dd>
<dd>Added FoxyProxy Basic detection (disabled for now)</dd>
<dd>Added Help translation form</dd>
<dd>Added "Show Pattern Proxy" option to show proxies when in "Proxy by Patterns" mode (#57) (Firefox only)</dd>
<dd>Added pattern matching to the Log display (#91)</dd>
<dd>Added proxy title to the toolbar icon mouse-over title display (#74)</dd>
<dd>Changed the global Proxy DNS to per-proxy setting (#75)</dd>
<dd>Fixed Firefox for Android compatibility (#60)</dd>
<dd>Increased the maximum height of the pattern section before scrolling</dd>
<dd>Removed Help document display on install/upgrade (#86)</dd>
<dd>Updated default Firefox proxy resetting (#59)</dd>
<dd>Updated PAC check to allow "file:" (#49)</dd>
<dd>Updated pattern "Add" button text due to localisation issues (#88)</dd>
<dd>Updated schema.json</dd>
<dd>Updated wildcard to regular expression conversion (#72)</dd>
<dt>8.6</dt>
<dd>Fixed an issue with migrating database from older versions (#69)</dd>
<dd>Updated user interface to disable inapplicable options in proxies (#52)</dd>
<dd>Updated wildcard to regular expression conversion (#72)</dd>
<dt>8.5</dt>
<dd>Skipped</dd>
<dt>8.4</dt>
<dd>Added light/dark theme detection for badge background color (#61)</dd>
<dd>Enabled to run with "controlled_by_other_extensions" on Firefox (#68)</dd>
<dd>Removed localhost and local network passthrough in Firefox (#50, #63, #64, #66, #71)</dd>
<dd>Updated Options save process to fill blank proxy header title display (#74)</dd>
<dt>8.3</dt>
<dd>Added enterprise policy and managed storage feature (experimental) (#42)</dd>
<dd>Added PAC "Store Locally" feature (#46) (experimental) (Chrome only)</dd>
<dd>Added PAC view feature</dd>
<dd>Fixed an issue with empty Global Exclude</dd>
<dd>Fixed an issue with upgrade sync data on Firefox (#53)</dd>
<dd>Updated hostname check to allow "file:" for Unix Domain Socket (#47)</dd>
<dd>Updated PAC check to allow "file:" (#49)</dd>
<dd>Updated user interface to disable inapplicable options in proxies</dd>
<dt>8.2</dt>
<dd>Added option to set the country to blank</dd>
<dd>Fixed an issue with upgrade sync data on Chrome (#45)</dd>
<dd>Updated Incognito process on Chrome</dd>
<dt>8.1</dt>
<dd>Added Drag and Drop sorting of proxies (#29)</dd>
<dd>Added duplicate proxy feature</dd>
<dd>Added Incognito/Container proxy (firefox-extension/issues/22, #33)</dd>
<dd>Added Keyboard Shortcut feature (firefox-extension/issues/217)</dd>
<dd>Added pattern import/export</dd>
<dd>Added port search to search filter</dd>
<dd>Added search filter to toolbar popup (#23)</dd>
<dd>Fixed a pattern conversion issue (#28)</dd>
<dd>Fixed an error in Sync (#36)</dd>
<dd>Fixed Tab Proxy icon when reloading a tab (#33)</dd>
<dd>Increased log content</dd>
<dd>Increased log display entries to 200</dd>
<dd>Increased log width to full screen</dd>
<dd>Updated Global Exclude process</dd>
<dt>8.0</dt>
<dd>Added complete Light/Dark Theme</dd>
<dd>Added Exclude host feature</dd>
<dd>Added Firefox on Android support (experimental) (#21)</dd>
<dd>Added Get Location feature</dd>
<dd>Added Global Exclude</dd>
<dd>Added Host Pattern to proxy feature</dd>
<dd>Added Import FoxyProxy Account (Chrome)</dd>
<dd>Added Import From URL</dd>
<dd>Added Limit WebRTC feature (Foxyproxy_Chrome/issues/4)</dd>
<dd>Added live Log (Firefox only)</dd>
<dd>Added Tab Proxy feature (Firefox only)</dd>
<dd>Changed "browsingData" to optional permissions</dd>
<dd>Dropped "browser_style" in preparation for MV3</dd>
<dd>Increased minimum version to Firefox 93 (released 2021-10-05)</dd>
<dd>Unified code for Firefox, Chrome, and other Chromium-based browsers</dd>
<dd>Unified storage to share between Firefox and Chrome</dd>
<dd>Updated code and style for manifest v3 (MV3) compatibility</dd>
<dd>Updated Import Proxy List</dd>
<dd>Updated User Interface</dd>
</dl>
<h2 id="credits">Credits</h2>
<dl>
<dt>Developer</dt>
<dd><a href="https://github.com/erosman" target="_blank">erosman</a></dd>
<dt>Translations</dt>
<dd><a href="https://github.com/foxyproxy/browser-extension" target="_blank">Github Public Repository</a></dd>
<dd>es: <a href="https://github.com/LuisAlfredo92" target="_blank">Luis Alfredo Figueroa Bracamontes</a></dd>
<dd>fa: <a href="https://github.com/axone13" target="_blank">Matin Kargar </a></dd>
<dd>fr: <a href="https://github.com/Hugo-C" target="_blank">Hugo-C</a></dd>
<dd>ja: <a href="https://github.com/yutayamate" target="_blank">Yuta Yamate</a></dd>
<dd>pl: Grzegorz Koryga</dd>
<dd>pt_BR: </dd>
<dd>ru: <a href="https://github.com/sosiska" target="_blank">Kirill Motkov</a>, <a href="https://github.com/krolchonok" target="_blank">krolchonok</a></dd>
<dd>uk: <a href="https://github.com/sponsors/webknjaz" target="_blank">Sviatoslav Sydorenko</a></dd>
<dd>zh_CN: <a href="https://github.com/wsxy162" target="_blank">FeralMeow </a></dd>
<dd>zh_TW: <a href="https://github.com/samuikaze" target="_blank">samuikaze</a></dd>
<dt>Founder</dt>
<dd>
<img class="figure" src="../image/ericjung.png" alt=""><br>
<a href="mailto:eric.jung@getfoxyproxy.org">Eric H. Jung</a><br>
Denver, Colorado, USA<br>
Developer
</dd>
</dl>
<p style="margin-top: 3em;">To support this free software, please please donate (<a href="https://buy.stripe.com/00gcNd1YQ2NQbKM148" target="_blank">Stripe</a>, <a href="https://www.paypal.me/ericjung2/5.99" target="_blank">PayPal</a>) or
<a href="https://getfoxyproxy.org/order/" target="_blank">buy dedicated VPN/Proxy Servers</a> in over 100 countries.<br>
<i>(including such remote places like <a href="https://wikipedia.org/wiki/Réunion" target="_blank">Reunion Island</a>)</i>
</p>
<p style="font: 1.5em cursive;">Thank you for using FoxyProxy!</p>
<img src="../image/logo.svg" style="width: 5em;" alt="">
</article>
</body>
</html>

View File

@@ -0,0 +1,49 @@
// import {Flag} from './flag.js';
import {Location} from './location.js';
export class Action {
// https://github.com/w3c/webextensions/issues/72#issuecomment-1848874359
// 'prefers-color-scheme' detection in Chrome background service worker
static dark = false;
static set(pref) {
// --- set action/browserAction
let title = '';
let text = '';
let color = this.dark ? '#444' : '#fff';
switch (pref.mode) {
case 'disable':
title = browser.i18n.getMessage('disable');
text = '⛔';
break;
case 'direct':
title = 'DIRECT';
text = '⮕';
break;
case 'pattern':
title = browser.i18n.getMessage('proxyByPatterns');
text = '🌐';
break;
default:
const item = pref.data.find(i => pref.mode === (i.type === 'pac' ? i.pac : `${i.hostname}:${i.port}`));
if (item) {
// Chrome 113-114 started having a bug showing unicode flags
// const flag = Flag.get(item.cc);
// const host = flag + ' ' + [item.hostname, item.port].filter(Boolean).join(':');
const host = [item.hostname, item.port].filter(Boolean).join(':');
title = [item.title, host, item.city, Location.get(item.cc)].filter(Boolean).join('\n');
// text = item.cc ? flag : item.hostname;
text = item.title || item.hostname;
color = item.color;
}
}
browser.action.setBadgeBackgroundColor({color});
browser.action.setTitle({title});
browser.action.setBadgeText({text});
}
}

View File

@@ -0,0 +1,88 @@
// ---------- Polyfill (Side Effect) -----------------------
// Promise based 'browser' namespace is used to avoid conflict
// Firefox 'chrome' API: MV2 callback | MV3 promise
// Firefox/Edge: browser namespace | Chrome/Opera: chrome namespace
globalThis.browser ??= chrome;
// ---------- Default Preferences --------------------------
export const pref = {
mode: 'disable',
sync: false,
autoBackup: false,
passthrough: '',
theme: '',
container: {},
commands: {},
data: []
};
// ---------- /Default Preferences -------------------------
// ---------- App ------------------------------------------
export class App {
// https://github.com/foxyproxy/firefox-extension/issues/220
// navigator.userAgent identification fails in custom userAgent and browser forks
// Chrome does not support runtime.getBrowserInfo()
// getURL: moz-extension: | chrome-extension: | safari-web-extension:
static firefox = browser.runtime.getURL('').startsWith('moz-extension:');
static basic = browser.runtime.getManifest().name === browser.i18n.getMessage('extensionNameBasic');
static android = navigator.userAgent.includes('Android');
// ---------- User Preferences ---------------------------
// not syncing mode & sync (to have a choice), data (will be broken into parts)
static syncProperties = Object.keys(pref).filter(i => !['mode', 'sync', 'data'].includes(i));
static defaultPref = JSON.stringify(pref);
static getDefaultPref() {
return JSON.parse(this.defaultPref);
}
static getPref() {
// update pref with the saved version
return browser.storage.local.get().then(result => {
Object.keys(result).forEach(i => pref[i] = result[i]);
});
}
// ---------- Helper functions ---------------------------
// https://bugs.chromium.org/p/chromium/issues/detail?id=478654
// Add support for SVG images in Web Notifications API -> CH107
// https://bugs.chromium.org/p/chromium/issues/detail?id=1353252
// svg broken from bg service worker
static notify(message, title = browser.i18n.getMessage('extensionName'), id = '') {
browser.notifications.create(id, {
type: 'basic',
iconUrl: '/image/icon48.png',
title,
message
});
}
static equal(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
static parseURL(url) {
// rebuild file://
url.startsWith('file://') && (url = 'http' + url.substring(4));
try { url = new URL(url); }
catch (error) {
alert(`${url}${error.message}`);
return {};
}
// check protocol
if (!['http:', 'https:', 'file:'].includes(url.protocol)) {
alert(`${url} ➜ Unsupported Protocol ${url.protocol}`);
return {};
}
return url;
}
static allowedTabProxy(url) {
return /^https?:\/\/.+|^about:(blank|newtab)$/.test(url);
}
}

View File

@@ -0,0 +1,51 @@
// webRequest.onAuthRequired: Firefox HTTP/HTTPS/WS/WSS | Chrome: HTTP/HTTPS
// 'webRequestAuthProvider' permission Chrome 108, Firefox 126
export class Authentication {
static {
this.data = {};
// prevent bad authentication loop
this.pending = {};
// webRequest.onAuthRequired is only called for HTTP and HTTPS/TLS proxy servers
const urls = ['<all_urls>'];
browser.webRequest.onAuthRequired.addListener(e => this.process(e), {urls}, ['blocking']);
browser.webRequest.onCompleted.addListener(e => this.clearPending(e), {urls});
browser.webRequest.onErrorOccurred.addListener(e => this.clearPending(e), {urls});
}
static init(data) {
// reset data
this.data = {};
data.forEach(i => {
const {hostname, port, username, password} = i;
hostname && port && username && password &&
(this.data[`${hostname}:${port}`] = {username, password});
});
}
static process(e) {
// true for Proxy-Authenticate, false for WWW-Authenticate
if (!e.isProxy) { return; }
// sending message to log.js
browser.runtime.sendMessage({id: 'onAuthRequired', e});
// already sent once and pending
if (this.pending[e.requestId]) {
return {cancel: true};
}
const {host, port} = e.challenger;
const authCredentials = this.data[`${host}:${port}`];
if (authCredentials) {
// prevent bad authentication loop
this.pending[e.requestId] = 1;
return {authCredentials};
}
}
static clearPending(e) {
delete this.pending[e.requestId];
}
}

View File

@@ -0,0 +1,35 @@
import {Sync} from "./sync.js";
import {Migrate} from './migrate.js';
import {Proxy} from './proxy.js';
import './commands.js';
// ---------- Process Preferences --------------------------
class ProcessPref {
static {
this.init();
}
static async init() {
const pref = await browser.storage.local.get();
// storage sync -> local update
await Sync.get(pref);
// migrate after storage sync check
await Migrate.init(pref);
// set proxy
Proxy.set(pref);
// add listener after migrate
Sync.init(pref);
}
}
// ---------- /Process Preferences -------------------------
// ---------- Initialisation -------------------------------
// browser.runtime.onInstalled.addListener(e => {
// // show help
// ['install', 'update'].includes(e.reason) && browser.tabs.create({url: '/content/help.html'});
// });

View File

@@ -0,0 +1,35 @@
import {App} from './app.js';
// ---------- browsingData (Side Effect) -------------------
class BrowsingData {
static {
document.querySelector('#deleteBrowsingData').addEventListener('click', () => this.process());
this.init();
}
static async init() {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/request
// Any permissions granted are retained by the extension, even over upgrade and disable/enable cycling.
// check if permission is granted
this.permission = await browser.permissions.contains({permissions: ['browsingData']});
}
static async process() {
if (!this.permission) {
// request permission
// Chrome appears to return true, granted silently without a popup prompt
this.permission = await browser.permissions.request({permissions: ['browsingData']});
if (!this.permission) { return; }
}
if (!confirm(browser.i18n.getMessage('deleteBrowsingDataConfirm'))) { return; }
browser.browsingData.remove({}, {
cookies: true,
indexedDB: true,
localStorage: true
})
.catch(error => App.notify(browser.i18n.getMessage('deleteBrowsingData') + '\n\n' + error.message));
}
}

View File

@@ -0,0 +1,218 @@
// ---------- Bulk Edit (Side Effect) ----------------------
class BulkEdit {
static {
const div = document.querySelector('.bulk-edit');
[this.t1, this.t2, this.s1, this.s2, this.select] = div.children;
this.t1.addEventListener('change', () => this.toggleProxy('t1'));
this.s1.addEventListener('change', () => this.toggleProxy('s1'));
this.t2.addEventListener('change', () => this.togglePattern());
this.select.addEventListener('change', () => this.process());
}
static toggleProxy(i) {
// remove previous selection
document.querySelector(`details.proxy.${i}`)?.classList.remove(i);
const n = this.getNumber(i);
if (!n) { return; }
document.querySelector(`details.proxy:nth-of-type(${n})`)?.classList.add(i);
// reselect t2
this.togglePattern();
}
static togglePattern() {
// remove previous selection
const prev = document.querySelector('.pattern-row.t2');
if (prev) {
prev.classList.remove('t2');
prev.closest('details').open = false;
}
const n = this.getNumber('t2');
if (!n) { return; }
const t = this.getNumber('t1') || this.getNumber('s1');
if (!t) { return; }
const elem = document.querySelector(`details.proxy:nth-of-type(${t}) .pattern-row:nth-of-type(${n})`);
if (elem) {
elem.classList.add('t2');
elem.closest('details').open = true;
}
}
static process() {
if (!this.select.value) { return; }
const id = this.select.value;
switch (id) {
case 'openAll':
case 'closeAll':
this.getProxies().forEach(i => i.open = id === 'openAll');
break;
case 'setType':
case 'setPort':
case 'setTitle':
case 'setUsername':
case 'setPassword':
let s2 = this.s2.value.trim();
if (!s2) { break; }
const ref = id.substring(3).toLowerCase();
if (ref === 'type') {
s2 = s2.toLowerCase();
if (!['http', 'https', 'socks4', 'socks5', 'quic', 'pac', 'direct'].includes(s2)) { break; }
}
document.querySelectorAll(`[data-id="${ref}"]`).forEach(i =>
s2.startsWith('+') ? i.value += s2.substring(1) : i.value = s2);
break;
case 'deleteProxy':
this.deleteProxy();
this.reset();
break;
case 'moveProxy':
this.moveProxy();
this.reset();
break;
case 'movePattern':
this.movePattern();
this.reset();
break;
}
// --- reset
this.select.selectedIndex = 0;
}
static reset() {
document.querySelectorAll('details.proxy:is(.t1, .s1), .pattern-row.t2').forEach(i =>
i.classList.remove('t1', 't2', 's1'));
// ['t1', 't2', 's1'].forEach(i => this[i].value = '');
}
static getProxies() {
return document.querySelectorAll('details.proxy');
}
static getNumber(i) {
return this[i].checkValidity() && this[i].value ? this[i].value : null;
}
static getSourceNumbers() {
const n = this.s2.value.match(/\d+-\d+|\d+/g);
if (!n) { return; }
let arr = [];
n.forEach(i => {
// check if number range e.g. 5-8
const [a, b] = i.split('-');
b ? arr.push(...Array.from({length: b - a + 1}, (_, i) => (a * 1) + i)) : arr.push(a);
});
// map to index (-1), sort, remove duplicates
arr = [...new Set(arr.map(i => i - 1).sort((a, b) => a - b))];
return arr.length ? arr : null;
}
static deleteProxy() {
const n = this.getSourceNumbers();
if (!n) { return; }
const p = this.getProxies();
n.forEach(i => p[i]?.remove());
}
static moveProxy() {
let n = this.getSourceNumbers();
if (!n) { return; }
const t1 = this.t1.value - 1;
const p = this.getProxies();
// filter target, map to elements, filter non-existing
n = n.filter(i => i !== t1).map(i => p[i]).filter(Boolean);
if (!n[0]) { return; }
// before target or after all
p[t1] ? p[t1].before(...n) : p[0].parentElement.append(...n);
}
static movePattern() {
const t1 = this.t1.value - 1;
const s1 = this.s1.value - 1;
switch (true) {
// move withing the same proxy
case t1 === -1 || t1 === s1:
s1 !== -1 && this.movePatternWithin(s1);
break;
// move all patterns to target
case s1 === -1:
this.movePatternAll(t1);
break;
// move source patterns to target
default:
this.movePatternSome(t1, s1);
}
}
static movePatternWithin(s1) {
let n = this.getSourceNumbers();
if (!n) { return; }
const p = this.getProxies();
if (!p[s1]) { return; }
const t2 = this.t2.value - 1;
// filter target, map to elements, filter non-existing
const pat = p[s1].querySelectorAll('.pattern-row');
n = n.filter(i => i !== t2).map(i => pat[i]).filter(Boolean);
if (!n[0]) { return; }
pat[t2] ? pat[t2].before(...n) : pat[0].parentElement.append(...n);
}
static movePatternAll(t1) {
const n = this.getSourceNumbers();
if (!n) { return; }
const p = this.getProxies();
if (!p[t1]) { return; }
// filter target, map to elements
const pat = [];
n.filter(i => i !== t1).forEach(i => p[i] && pat.push(...p[i].querySelectorAll('.pattern-row')));
const target = p[t1].querySelector('.pattern-box');
const row = target.children?.[this.t2.value - 1];
row ? row.before(...pat) : target.append(...pat);
}
static movePatternSome(t1, s1) {
let n = this.getSourceNumbers();
if (!n) { return; }
const p = this.getProxies();
if (!p[t1] || !p[s1]) { return; }
// map to elements, filter non-existing
const pat = p[s1].querySelectorAll('.pattern-row');
n = n.map(i => pat[i]).filter(Boolean);
if (!n[0]) { return; }
const target = p[t1].querySelector('.pattern-box');
const row = target.children?.[this.t2.value - 1];
row ? row.before(...n) : target.append(...n);
}
}

View File

@@ -0,0 +1,21 @@
export class Color {
static getRandom() {
return this.colors[Math.floor(Math.random() * this.colors.length)];
}
static colors = [
'#faebd7', '#00ffff', '#7fffd4', '#f5f5dc', '#ffe4c4', '#ffebcd', '#0000ff', '#8a2be2', '#a52a2a', '#deb887',
'#5f9ea0', '#7fff00', '#d2691e', '#ff7f50', '#6495ed', '#fff8dc', '#dc143c', '#00008b', '#008b8b', '#b8860b',
'#a9a9a9', '#006400', '#bdb76b', '#8b008b', '#556b2f', '#ff8c00', '#9932cc', '#8b0000', '#e9967a', '#8fbc8f',
'#483d8b', '#2f4f4f', '#00ced1', '#9400d3', '#ff1493', '#00bfff', '#696969', '#1e90ff', '#b22222', '#228b22',
'#ff00ff', '#ffd700', '#daa520', '#808080', '#008000', '#adff2f', '#ff69b4', '#cd5c5c', '#4b0082', '#f0e68c',
'#7cfc00', '#fffacd', '#add8e6', '#f08080', '#e0ffff', '#fafad2', '#d3d3d3', '#90ee90', '#ffb6c1', '#ffa07a',
'#20b2aa', '#87cefa', '#778899', '#b0c4de', '#00ff00', '#32cd32', '#800000', '#66cdaa', '#0000cd', '#ba55d3',
'#9370db', '#3cb371', '#7b68ee', '#00fa9a', '#48d1cc', '#c71585', '#191970', '#ffe4e1', '#ffe4b5', '#ffdead',
'#000080', '#fdf5e6', '#808000', '#6b8e23', '#ffa500', '#ff4500', '#da70d6', '#eee8aa', '#98fb98', '#afeeee',
'#db7093', '#ffefd5', '#ffdab9', '#cd853f', '#ffc0cb', '#dda0dd', '#b0e0e6', '#800080', '#ff0000', '#bc8f8f',
'#4169e1', '#8b4513', '#fa8072', '#f4a460', '#2e8b57', '#fff5ee', '#a0522d', '#87ceeb', '#6a5acd', '#708090',
'#00ff7f', '#4682b4', '#d2b48c', '#008080', '#d8bfd8', '#ff6347', '#40e0d0', '#ee82ee', '#f5deb3', '#ffff00',
'#9acd32'];
}

View File

@@ -0,0 +1,77 @@
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/commands/onCommand
// https://developer.chrome.com/docs/extensions/reference/commands/#event-onCommand
// Chrome commands returns command, tab
// https://bugzilla.mozilla.org/show_bug.cgi?id=1843866
// Add tab parameter to commands.onCommand (fixed in Firefox 126)
import {App} from './app.js';
import {Proxy} from './proxy.js';
import {OnRequest} from './on-request.js';
// ---------- Commands (Side Effect) ------------------------
class Commands {
static {
// commands is not supported on Android
browser.commands?.onCommand.addListener((...e) => this.process(...e));
}
static async process(name, tab) {
// firefox only Tab Proxy
const tabProxy = ['setTabProxy', 'unsetTabProxy'].includes(name);
if (!App.firefox && tabProxy) { return; }
const pref = await browser.storage.local.get();
// only Tab Proxy allowed for storage.managed
if (pref.managed && !tabProxy) { return; }
const host = pref.commands[name];
let proxy;
switch (name) {
case 'proxyByPatterns':
this.set(pref, 'pattern');
break;
case 'disable':
this.set(pref, 'disable');
break;
case 'setProxy':
host && this.set(pref, host);
break;
case 'includeHost':
case 'excludeHost':
if (!host) { break; }
proxy = this.findProxy(pref, host);
proxy && Proxy.includeHost(pref, proxy, tab, name);
break;
case 'setTabProxy':
if (!host) { break; }
proxy = this.findProxy(pref, host);
proxy && OnRequest.setTabProxy(tab, proxy);
break;
case 'unsetTabProxy':
OnRequest.setTabProxy(tab);
break;
}
}
static findProxy(pref, host) {
return host && pref.data.find(i => i.active && host === `${i.hostname}:${i.port}`);
}
static set(pref, mode) {
pref.mode = mode;
// save mode
browser.storage.local.set({mode});
// set proxy without menus update
Proxy.set(pref, true);
}
}

View File

@@ -0,0 +1,161 @@
/* ----- Light Theme ----- */
:root {
--color: #000;
--bg: #fff;
--alt-bg: #f5f5f5;
--hover: #eaeaea;
--highlight: #f90;
--body-bg: #630;
--header: #c60;
--nav-bg: #420;
--nav-hover: #851;
--nav-color: cornsilk;
--btn-bg: #f90;
--btn-hover: #e70;
--link: #e70;
--border: #ddd;
/* --shadow: #0004; */
--dim: #777;
--tr: #f5f5f5;
}
/* ----- Dark Theme ----- */
@media screen and (prefers-color-scheme: dark) {
:root {
--color: #fff;
--bg: #420;
--alt-bg: #666;
--hover: #444;
/* --body-bg: #630; */
--header: #e70;
--btn-bg: #f90;
--btn-hover: #e70;
--link: #f90;
--border: #777;
/* --shadow: #fff8; */
--dim: #ccc;
--tr: #531;
}
}
/* ----- General ----- */
body {
color: var(--color);
background-color: var(--body-bg);
padding: 0;
margin: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
article {
padding: 0;
margin: 0;
}
section {
padding: 0;
}
a {
color: var(--link);
text-decoration: none;
}
select,
textarea,
input[type="number"],
input[type="text"],
input[type="password"],
input[type="url"] {
width: 100%;
color: inherit;
background-color: var(--alt-bg);
border: 1px solid var(--border);
border-radius: 0.3em;
}
:is(select,
input[type="number"],
input[type="text"],
input[type="password"],
input[type="url"]):hover {
background-color: var(--hover);
}
label[for],
input[type="checkbox"],
summary,
.pointer {
cursor: pointer;
}
::placeholder {
opacity: 0.5;
color: inherit;
font-style: italic;
}
.invalid,
input:invalid {
box-shadow: 1px 1px 4px #f20, -1px -1px 4px #f20;
}
/* ----- Buttons ----- */
button,
label.flat {
background-color: var(--btn-bg);
border: none;
color: inherit;
cursor: pointer;
text-align: center;
white-space: nowrap;
}
button.flat,
label.flat {
display: inline-block;
font-size: 0.9em;
color: #fff;
border-radius: 5px;
padding: 0.4em 1em;
min-width: 8em;
}
button:hover,
label.flat:hover {
background-color: var(--btn-hover);
}
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.4;
}
button.plain {
background-color: unset;
padding: 0;
margin: 0;
min-width: 1em;
}
button[type="submit"] {
display: table;
color: #fff;
font-size:0.9em;
border-radius: 5px;
padding: 0.5em 5em;
margin: 1em auto 0;
}
/* ----- /Buttons ----- */

View File

@@ -0,0 +1,30 @@
// ---------- Drag and Drop (Side Effect) ------------------
class Drag {
static {
this.proxyDiv = document.querySelector('div.proxy-div');
this.proxyDiv.addEventListener("dragstart", e => this.dragstart(e));
this.proxyDiv.addEventListener('dragover', e => this.dragover(e));
this.proxyDiv.addEventListener('dragend', e => this.dragend(e));
this.target = null;
}
static dragstart(e) {
if (e.target.localName === 'input') {
e.preventDefault();
e.stopPropagation();
}
}
static dragover(e) {
this.target = e.target.closest('details');
}
static dragend(e) {
if (!this.target) { return; }
const arr = [...this.proxyDiv.children];
arr.indexOf(e.target) > arr.indexOf(this.target) ? this.target.before(e.target) : this.target.after(e.target);
this.target = null;
}
}

View File

@@ -0,0 +1,54 @@
// ---------- Unicode flag ---------------------------------
export class Flag {
static get(cc) {
cc = /^[A-Z]{2}$/i.test(cc) && cc.toUpperCase();
return cc ? String.fromCodePoint(...[...cc].map(i => i.charCodeAt() + 127397)) : '🌎';
}
static show(item) {
switch (true) {
case !!item.cc:
return this.get(item.cc);
case item.type === 'direct':
return '⮕';
case this.isLocal(item.hostname):
return '🖥️';
default:
return '🌎';
}
}
static isLocal(host) {
// check local network
const isIP = /^[\d.:]+$/.test(host);
switch (true) {
// --- localhost & <local>
// case host === 'localhost':
// plain hostname (no dots)
case !host.includes('.'):
// *.localhost
case host.endsWith('.localhost'):
// --- IPv4
// case host === '127.0.0.1':
// 127.0.0.1 up to 127.255.255.254
case isIP && host.startsWith('127.'):
// 169.254.0.0/16 - 169.254.0.0 to 169.254.255.255
case isIP && host.startsWith('169.254.'):
// 192.168.0.0/16 - 192.168.0.0 to 192.168.255.255
case isIP && host.startsWith('192.168.'):
// --- IPv6
// case host === '[::1]':
// literal IPv6 [::1]:80 with/without port
case host.startsWith('[::1]'):
// literal IPv6 [FE80::]/10
case host.toUpperCase().startsWith('[FE80::]'):
return true;
}
}
}

View File

@@ -0,0 +1,45 @@
import {Spinner} from './spinner.js';
// ---------- Get Location (Side Effect) -------------------
class GetLocation {
static {
this.proxyDiv = document.querySelector('div.proxy-div');
document.querySelector('.proxy-top button[data-i18n="getLocation"]').addEventListener('click', () => this.process());
}
static async process() {
const ignore = ['127.0.0.1', 'localhost'];
let {data} = await browser.storage.local.get({data: []});
data = data.filter(i => i.type !== 'direct' && !ignore.includes(i.hostname)).map(i => i.hostname);
if (!data[0]) { return; }
// remove duplicates
const hosts = [...new Set(data)];
Spinner.show();
fetch('https://getfoxyproxy.org/webservices/lookup.php?' + hosts.join('&'))
.then(response => response.json())
.then(json => this.updateLocation(json))
.catch(error => {
Spinner.hide();
alert(error);
});
}
static updateLocation(json) {
// update display
this.proxyDiv.querySelectorAll('[data-id="cc"], [data-id="city"]').forEach(i => {
const {hostname, id} = i.dataset;
// cache old value to compare
const value = i.value;
json[hostname]?.[id] && (i.value = json[hostname][id]);
// dispatch change event
id === 'cc' && i.value !== value && i.dispatchEvent(new Event('change'));
});
Spinner.hide();
}
}

View File

@@ -0,0 +1,18 @@
// ---------- Internationalization (Side Effect) -----------
class I18n {
static {
document.querySelectorAll('template').forEach(i => this.set(i.content));
this.set();
// show after
// document.body.style.opacity = 1;
}
static set(target = document) {
target.querySelectorAll('[data-i18n]').forEach(elem => {
let [text, attr] = elem.dataset.i18n.split('|');
text = browser.i18n.getMessage(text);
attr ? elem.setAttribute(attr, text) : elem.append(text);
});
}
}

View File

@@ -0,0 +1,315 @@
@import 'default.css';
@import 'theme.css';
/* ----- General ----- */
:root {
--nav-height: 2.5rem;
}
html {
scroll-padding-top: calc(var(--nav-height) + 0.5rem);
}
body {
/* Chrome sets font-size to 75% (16px x 75% = 12px) */
font-size: unset;
}
img {
vertical-align: text-bottom;
}
article {
padding: 2em;
background-color: var(--bg);
}
/* ----- h1-h5 ----- */
h2 {
color: var(--header);
font-size: 2.5em;
border-bottom: 1px solid var(--border);
font-weight: normal;
}
h2:first-of-type {
margin-top: 0;
}
h3 {
font-size: 1.5em;
font-weight: normal;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1em;
}
:not(h2) + h3 {
margin-top: 1.5em;
}
:is(h1, h2, h3, h4, h5) span {
color: var(--dim);
font-size: 0.8em;
font-style: italic;
font-weight: normal;
margin-left: 0.5em;
}
/* ----- /h1-h5 ----- */
p {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
pre {
border-left: 3px solid #ccc;
padding: 0.5em 1em;
}
code {
padding: 0 0.3em;
background-color: var(--hover);
font-size: 1.1em;
}
blockquote {
color: var(--color);
padding: 1em 3.5em;
font-style: italic;
position: relative;
font-size: 0.9em;
}
blockquote::before,
blockquote::after {
color: #ccc;
opacity: 0.6;
font-size: 4em;
position: absolute;
content: '❝';
top: 0;
left: 0.1em;
}
blockquote::after {
bottom: -0.5em;
right: 0.5em;
}
cite {
display: block;
margin-top: 1em;
color: #999;
}
cite::before {
content: '— source: ';
}
img.figure {
border-radius: 1em;
border: 4px solid var(--nav-hover);
width: 140px;
}
dt {
font-size: 1.1em;
}
dd + dt {
margin-top: 1em;
}
dd > dl {
margin: 1em auto;
}
ol, ul {
margin: 0;
}
th span,
dt span,
dd span,
li span {
margin-left: 0.5em;
color: var(--dim);
font-style: italic;
font-weight: normal;
letter-spacing: normal;
font-size: 0.9em;
}
mark {
color: var(--header);
background-color: unset;
}
.scroll {
max-height: 25em;
overflow: auto;
}
/* ----- About ----- */
.about dt {
display: table;
border-bottom: 1px solid var(--border);
margin-bottom: 0.2em;
min-width: 15vw;
font-weight: bold;
}
/* ----- /About ----- */
/* ----- Navigation ----- */
nav {
background-color: var(--bg);
height: var(--nav-height);
position: sticky;
top: 0;
z-index: 1;
box-shadow: 0 3px 6px #0004;
display: grid;
grid-auto-flow: column;
justify-content: start;
align-items: center;
}
nav a {
color: var(--color);
padding: 0.5em 1em;
}
nav a:hover {
background-color: var(--hover);
}
/* ----- /Navigation ----- */
/* ----- Table ----- */
table {
border-collapse: collapse;
border: 1px solid var(--border);
margin-bottom: 1em;
width: calc(100% - 2.5rem);
font-size: 0.9em;
}
caption {
padding: 0.5em;
}
tr:nth-child(2n) {
background-color: var(--alt-bg);
}
th,
td {
border: 1px solid var(--border);
vertical-align: top;
padding: 0.5em;
}
thead th {
font-size: 1.2em;
}
tbody th {
min-width: 10em;
text-align: left;
}
td pre,
.code td,
td.code {
font-family: monospace;
font-size: 1.2em;
}
.slim th,
.slim td {
padding: 0.2em 0.5em;
}
/* ----- /Table ----- */
/* ----- note, footnote, warning, experimental ----- */
.note,
.warning {
border: 1px solid var(--border);
border-radius: 0.5em;
border-left: 3px solid #17f;
padding: 0.3em 0.5em 0.3em 2em;
margin-top: 0.5em;
position: relative;
display: table;
}
.note::before,
.warning::before {
content: 'ⓘ';
color: #17f;
position: absolute;
top: 0;
left: 0.5em;
}
.warning {
border-left-color: #f90;
}
.warning::before {
content: '⚠️';
}
.experimental::after {
content: '';
background: url('../image/beaker.svg') no-repeat center / contain;
display: inline-block;
width: 1em;
height: 1em;
margin-left: 0.5em;
}
.footnote {
font-size: 0.9em;
font-style: italic;
}
/* ----- span links ----- */
.chrome-extension,
.moz-extension {
cursor: pointer;
font-style: normal;
font-size: 0.8em;
display: none;
}
/* ----- Translate ----- */
.translate {
display: grid;
grid-auto-flow: column;
justify-content: end;
align-items: center;
gap: 0.5em;
padding: 1em 1em 0;
background-color: var(--bg);
}
.translate select {
width: auto;
}
.translate input[type="submit"] {
color: inherit;
background-color: var(--alt-bg);
border: 1px solid var(--border);
border-radius: 0.3em;
padding: 0.2em 0.5em;
}
/* ----- /Translate ----- */

View File

@@ -0,0 +1,98 @@
import {App} from './app.js';
import {Proxies} from './options-proxies.js';
import {Spinner} from './spinner.js';
import {Toggle} from './toggle.js';
import {Nav} from './nav.js';
// ---------- Import FoxyProxy Account (Side Effect) -------
class ImportAccount {
static {
this.username = document.querySelector('.import-account #username');
this.password = document.querySelector('.import-account #password');
Toggle.password(this.password.nextElementSibling);
document.querySelector('.import-account button[data-i18n="import"]').addEventListener('click', () => this.process());
}
static async process() {
// --- check username/password
const username = this.username.value.trim();
const password = this.password.value.trim();
if (!username || !password) {
alert(browser.i18n.getMessage('userPassError'));
return;
}
Spinner.show();
const options = [...document.querySelectorAll('.import-account .account-options select')].map(i => i.value);
// Array(3) [ "https", "hostname", "alt" ]
const url = options.includes('alt') ?
'https://bilestoad.com/webservices/get-accounts.php' :
'https://getfoxyproxy.org/webservices/get-accounts.php';
const ip = options.includes('ip');
const socks = options.includes('socks5');
const https = options.includes('https');
const proxyDiv = document.querySelector('div.proxy-div');
const docFrag = document.createDocumentFragment();
// --- fetch data
const data = await this.getAccount(url, username, password);
if (data) {
data.forEach(i => {
// proxy template
const pxy = {
active: true,
title: i.hostname.split('.')[0],
type: socks ? 'socks5' : https ? 'https' : 'http',
hostname: ip ? i.ip : i.hostname,
port: socks ? i.socks5_port : https ? i.ssl_port : i.port[0],
username: i.username,
password: i.password,
// convert UK to ISO 3166-1 GB
cc: i.country_code === 'UK' ? 'GB' : i.country_code,
city: i.city,
// random color will be set
color: '',
pac: '',
pacString: '',
proxyDNS: true,
include: [],
exclude: [],
tabProxy: [],
};
docFrag.append(Proxies.addProxy(pxy));
});
proxyDiv.append(docFrag);
Nav.get('proxies');
}
Spinner.hide();
}
static async getAccount(url, username, password) {
// --- fetch data
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`
})
.then(response => response.json())
.then(data => {
if (!Array.isArray(data) || !data[0]?.hostname) {
App.notify(browser.i18n.getMessage('error'));
return;
}
// import active accounts only
data = data.filter(i => i.active === 'true');
// sort by country
data.sort((a, b) => a.country.localeCompare(b.country));
return data;
})
.catch(error => App.notify(browser.i18n.getMessage('error') + '\n\n' + error.message));
}
}

View File

@@ -0,0 +1,76 @@
import {App} from './app.js';
// ---------- Import/Export Preferences --------------------
export class ImportExport {
// pref references the same object in the memory and its value gets updated
static init(pref, callback) {
this.callback = callback;
document.getElementById('file').addEventListener('change', e => this.import(e, pref));
document.getElementById('export').addEventListener('click', () => this.export(pref));
}
// import preferences
static import(e, pref) {
const file = e.target.files[0];
switch (true) {
case !file: App.notify(browser.i18n.getMessage('error'));
return;
// check file MIME type
case !['text/plain', 'application/json'].includes(file.type):
App.notify(browser.i18n.getMessage('fileTypeError'));
return;
}
this.fileReader(file, r => this.readData(r, pref));
}
static readData(data, pref) {
try { data = JSON.parse(data); }
catch {
// display the error
App.notify(browser.i18n.getMessage('fileParseError'));
return;
}
// update pref with the saved version
Object.keys(pref).forEach(i => Object.hasOwn(data, i) && (pref[i] = data[i]));
// successful import
this.callback();
}
// export preferences
static export(pref, saveAs = true, folder = '') {
const data = JSON.stringify(pref, null, 2);
const filename = `${folder}${browser.i18n.getMessage('extensionName')}_${new Date().toISOString().substring(0, 10)}.json`;
this.saveFile({data, filename, type: 'application/json', saveAs});
}
static saveFile({data, filename, saveAs = true, type = 'text/plain'}) {
if (!browser.downloads) {
const a = document.createElement('a');
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(data);
a.setAttribute('download', filename);
a.dispatchEvent(new MouseEvent('click'));
return;
}
const blob = new Blob([data], {type});
browser.downloads.download({
url: URL.createObjectURL(blob),
filename,
saveAs,
conflictAction: 'uniquify'
})
// eslint-disable-next-line no-console
.catch(console.log);
}
static fileReader(file, callback) {
const reader = new FileReader();
reader.onloadend = () => callback(reader.result);
reader.onerror = () => App.notify(browser.i18n.getMessage('fileReadError'));
reader.readAsText(file);
}
}

View File

@@ -0,0 +1,132 @@
import {Proxies} from './options-proxies.js';
import {Nav} from './nav.js';
// ---------- Import List (Side Effect) --------------------
class ImportList {
static {
this.textarea = document.querySelector('.import-proxy-list textarea');
document.querySelector('.import-proxy-list button').addEventListener('click', () => this.process());
}
static process() {
this.textarea.value = this.textarea.value.trim();
if (!this.textarea.value) { return; }
const proxyDiv = document.querySelector('div.proxy-div');
const docFrag = document.createDocumentFragment();
for (const item of this.textarea.value.split(/\n+/)) {
// simple vs Extended format
const pxy = item.includes('://') ? this.parseExtended(item) : this.parseSimple(item);
// end on error
if (!pxy) { return; }
docFrag.append(Proxies.addProxy(pxy));
}
proxyDiv.append(docFrag);
Nav.get('proxies');
}
static parseSimple(item) {
// example.com:3128:user:pass
const [hostname, port, username = '', password = ''] = item.split(':');
if (!hostname || !(port * 1)) {
alert(`Error: ${item}`);
return;
}
const type = port === '443' ? 'https' : 'http';
// proxy template
const pxy = {
active: true,
title: '',
type,
hostname,
port,
username,
password,
cc: '',
city: '',
color: '',
pac: '',
pacString: '',
proxyDNS: true,
include: [],
exclude: [],
tabProxy: [],
};
return pxy;
}
static parseExtended(item) {
// https://user:password@78.205.12.1:21?color=ff00bc&title=work%20proxy
// https://example.com:443?active=false&title=Work&username=abcd&password=1234&cc=US&city=Miami
let url;
try { url = new URL(item); }
catch (error) {
alert(`${error}\n\n${item}`);
return;
}
// convert old schemes to type
let type = url.protocol.slice(0, -1);
const scheme = {
proxy: 'http',
ssl: 'https',
socks: 'socks5',
};
scheme[type] && (type = scheme[type]);
// https://bugzilla.mozilla.org/show_bug.cgi?id=1851426
// Reland URL: protocol setter needs to be more restrictive around file (fixed in Firefox 120)
// https://bugzilla.mozilla.org/show_bug.cgi?id=1603699
// Enable DefaultURI use for unknown schemes (fixed in Firefox 122)
// missing hostname, port with socks protocol (#120)
!url.hostname && (url = new URL('http:' + item.substring(url.protocol.length)));
const {hostname, port, username, password} = url;
// set to pram, can be overridden in searchParams
const pram = {type, hostname, port, username, password};
// prepare object, make parameter keys case-insensitive
for (const [key, value] of url.searchParams) {
pram[key.toLowerCase()] = value;
}
// fix missing default port
const defaultPort = {
http: '80',
https: '443',
ws: '80',
wss: '443'
};
!pram.port && defaultPort[type] && (pram.port = defaultPort[type]);
// proxy template
const pxy = {
// defaults to true
active: pram.active !== 'false',
title: pram.title || '',
type: pram.type.toLowerCase(),
hostname: pram.hostname,
port: pram.port,
username: decodeURIComponent(pram.username),
password: decodeURIComponent(pram.password),
cc: (pram.cc || pram.countrycode || '').toUpperCase(),
city: pram.city || '',
color: pram.color ? '#' + pram.color : '',
pac: pram.pac || (pram.type === 'pac' && url.origin + url.pathname) || '',
pacString: '',
// defaults to true
proxyDNS: pram.proxydns !== 'false',
include: [],
exclude: [],
tabProxy: [],
};
return pxy;
}
}

View File

@@ -0,0 +1,43 @@
import {App} from './app.js';
import {Migrate} from './migrate.js';
// ---------- Import Older Preferences ---------------------
export class ImportOlder {
// pref references the same object in the memory and its value gets updated
static init(pref, callback) {
this.callback = callback;
document.querySelector('.import-from-older input').addEventListener('change', e => this.process(e, pref));
}
static process(e, pref) {
const file = e.target.files[0];
switch (true) {
case !file: App.notify(browser.i18n.getMessage('error')); return;
// check file MIME type
case !['text/plain', 'application/json'].includes(file.type):
App.notify(browser.i18n.getMessage('fileTypeError'));
return;
}
const reader = new FileReader();
reader.onloadend = () => this.parseJSON(reader.result, pref);
reader.onerror = () => App.notify(browser.i18n.getMessage('fileReadError'));
reader.readAsText(file);
}
static parseJSON(data, pref) {
try { data = JSON.parse(data); }
catch {
// display the error
App.notify(browser.i18n.getMessage('fileParseError'));
return;
}
data = Object.hasOwn(data, 'settings') ? Migrate.convert3(data) : Migrate.convert7(data);
// update pref with the saved version
Object.keys(pref).forEach(i => Object.hasOwn(data, i) && (pref[i] = data[i]));
this.callback();
}
}

View File

@@ -0,0 +1,38 @@
import {App} from './app.js';
import {Spinner} from './spinner.js';
// ---------- Import from URL ------------------------------
export class ImportUrl {
static {
this.input = document.querySelector('.import-from-url input');
}
// pref references the same object in the memory and its value gets updated
static init(pref, callback) {
this.callback = callback;
document.querySelector('.import-from-url button').addEventListener('click', () => this.process(pref));
}
static process(pref) {
this.input.value = this.input.value.trim();
if (!this.input.value) { return; }
Spinner.show();
// --- fetch data
fetch(this.input.value)
.then(response => response.json())
.then(data => {
// update pref with the saved version
Object.keys(pref).forEach(i => Object.hasOwn(data, i) && (pref[i] = data[i]));
this.callback();
Spinner.hide();
})
.catch(error => {
App.notify(browser.i18n.getMessage('error') + '\n\n' + error.message);
Spinner.hide();
});
}
}

View File

@@ -0,0 +1,16 @@
import {App} from './app.js';
// ---------- Incognito Access (Side Effect) ---------------
class IncognitoAccess {
static {
// https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/proxy/settings
// Changing proxy settings requires private browsing window access because proxy settings affect private and non-private windows.
// https://github.com/w3c/webextensions/issues/429
// Inconsistency: incognito in proxy.settings
// https://bugzilla.mozilla.org/show_bug.cgi?id=1725981
// proxy.settings is not supported on Android
App.firefox && !App.android && browser.extension.isAllowedIncognitoAccess()
.then(response => !response && alert(browser.i18n.getMessage('incognitoAccessError')));
}
}

View File

@@ -0,0 +1,262 @@
export class Location {
static get(cc = '') {
return this.countryCode[cc] || '';
}
// ISO 3166-1 country code
static countryCode = {
AA: "",
AD: "Andorra",
AE: "United Arab Emirates",
AF: "Afghanistan",
AG: "Antigua and Barbuda",
AI: "Anguilla",
AL: "Albania",
AM: "Armenia",
AO: "Angola",
AQ: "Antarctica",
AR: "Argentina",
AS: "American Samoa",
AT: "Austria",
AU: "Australia",
AW: "Aruba",
AX: "Åland Islands",
AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina",
BB: "Barbados",
BD: "Bangladesh",
BE: "Belgium",
BF: "Burkina Faso",
BG: "Bulgaria",
BH: "Bahrain",
BI: "Burundi",
BJ: "Benin",
BL: "Saint Barthélemy",
BM: "Bermuda",
BN: "Brunei Darussalam",
BO: "Bolivia, Plurinational State of",
BQ: "Bonaire, Sint Eustatius and Saba",
BR: "Brazil",
BS: "Bahamas",
BT: "Bhutan",
BV: "Bouvet Island",
BW: "Botswana",
BY: "Belarus",
BZ: "Belize",
CA: "Canada",
CC: "Cocos [Keeling] Islands",
CD: "Congo, the Democratic Republic of the",
CF: "Central African Republic",
CG: "Congo, Republic of the",
CH: "Switzerland",
CI: "Ivory Coast (Côte d'Ivoire)",
CK: "Cook Islands",
CL: "Chile",
CM: "Cameroon",
CN: "China",
CO: "Colombia",
CR: "Costa Rica",
CU: "Cuba",
CV: "Cabo Verde",
CW: "Curaçao",
CX: "Christmas Island",
CY: "Cyprus",
CZ: "Czechia",
DE: "Germany",
DJ: "Djibouti",
DK: "Denmark",
DM: "Dominica",
DO: "Dominican Republic",
DZ: "Algeria",
EC: "Ecuador",
EE: "Estonia",
EG: "Egypt",
EH: "Western Sahara",
ER: "Eritrea",
ES: "Spain",
ET: "Ethiopia",
EU: "European Union",
FI: "Finland",
FJ: "Fiji",
FK: "Falkland Islands (Malvinas)",
FM: "Micronesia, Federated States of",
FO: "Faroe Islands",
FR: "France",
GA: "Gabon",
GB: "United Kingdom",
GD: "Grenada",
GE: "Georgia",
GF: "French Guiana",
GG: "Guernsey",
GH: "Ghana",
GI: "Gibraltar",
GL: "Greenland",
GM: "Gambia",
GN: "Guinea",
GP: "Guadeloupe",
GQ: "Equatorial Guinea",
GR: "Greece",
GS: "South Georgia and the South Sandwich Islands",
GT: "Guatemala",
GU: "Guam",
GW: "Guinea-Bissau",
GY: "Guyana",
HK: "Hong Kong",
HM: "Heard Island and McDonald Islands",
HN: "Honduras",
HR: "Croatia",
HT: "Haiti",
HU: "Hungary",
ID: "Indonesia",
IE: "Ireland",
IL: "Israel",
IM: "Isle of Man",
IN: "India",
IO: "British Indian Ocean Territory",
IQ: "Iraq",
IR: "Iran, Islamic Republic Of",
IS: "Iceland",
IT: "Italy",
JE: "Jersey",
JM: "Jamaica",
JO: "Jordan (Hashemite Kingdom of Jordan)",
JP: "Japan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KI: "Kiribati",
KM: "Comoros",
KN: "St Kitts and Nevis",
KP: "North Korea",
KR: "South Korea",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos (Lao People's Democratic Republic)",
LB: "Lebanon",
LC: "Saint Lucia",
LI: "Liechtenstein",
LK: "Sri Lanka",
LR: "Liberia",
LS: "Lesotho",
LT: "Republic of Lithuania",
LU: "Luxembourg",
LV: "Latvia",
LY: "Libya",
MA: "Morocco",
MC: "Monaco",
MD: "Moldova, Republic of",
ME: "Montenegro",
MF: "Saint Martin (French part)",
MG: "Madagascar",
MH: "Marshall Islands",
MK: "North Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MO: "Macao",
MP: "Northern Mariana Islands",
MQ: "Martinique",
MR: "Mauritania",
MS: "Montserrat",
MT: "Malta",
MU: "Mauritius",
MV: "Maldives",
MW: "Malawi",
MX: "Mexico",
MY: "Malaysia",
MZ: "Mozambique",
NA: "Namibia",
NC: "New Caledonia",
NE: "Niger",
NF: "Norfolk Island",
NG: "Nigeria",
NI: "Nicaragua",
NL: "Netherlands",
NO: "Norway",
NP: "Nepal",
NR: "Nauru",
NU: "Niue",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PE: "Peru",
PF: "French Polynesia",
PG: "Papua New Guinea",
PH: "Philippines",
PK: "Pakistan",
PL: "Poland",
PM: "Saint Pierre and Miquelon",
PN: "Pitcairn Islands",
PR: "Puerto Rico",
PS: "Palestine",
PT: "Portugal",
PW: "Palau",
PY: "Paraguay",
QA: "Qatar",
RE: "Réunion",
RO: "Romania",
RS: "Serbia",
RU: "Russia (Russian Federation)",
RW: "Rwanda",
SA: "Saudi Arabia",
SB: "Solomon Islands",
SC: "Seychelles",
SD: "Sudan",
SE: "Sweden",
SG: "Singapore",
SH: "Saint Helena",
SI: "Slovenia",
SJ: "Svalbard and Jan Mayen",
SK: "Slovakia",
SL: "Sierra Leone",
SM: "San Marino",
SN: "Senegal",
SO: "Somalia",
SR: "Suriname",
SS: "South Sudan",
ST: "São Tomé and Príncipe",
SV: "El Salvador",
SX: "Sint Maarten (Dutch part)",
SY: "Syria",
SZ: "Eswatini",
TC: "Turks and Caicos Islands",
TD: "Chad",
TF: "French Southern Territories",
TG: "Togo",
TH: "Thailand",
TJ: "Tajikistan",
TK: "Tokelau",
TL: "Democratic Republic of Timor-Leste",
TM: "Turkmenistan",
TN: "Tunisia",
TO: "Tonga",
TR: "Türkiye",
TT: "Trinidad and Tobago",
TV: "Tuvalu",
TW: "Taiwan",
TZ: "Tanzania",
UA: "Ukraine",
UG: "Uganda",
UM: "U.S. Minor Outlying Islands",
US: "United States of America",
UY: "Uruguay",
UZ: "Uzbekistan",
VA: "Vatican City",
VC: "Saint Vincent and the Grenadines",
VE: "Venezuela",
VG: "British Virgin Islands",
VI: "U.S. Virgin Islands",
VN: "Vietnam",
VU: "Vanuatu",
WF: "Wallis and Futuna",
WS: "Samoa",
XK: "Kosovo",
YE: "Yemen",
YT: "Mayotte",
ZA: "South Africa",
ZM: "Zambia",
ZW: "Zimbabwe",
};
}

View File

@@ -0,0 +1,114 @@
/* ----- Log ----- */
/* ----- Light Theme ----- */
:root {
--mark: #080;
}
/* ----- Dark Theme ----- */
@media screen and (prefers-color-scheme: dark) {
:root {
--mark: #0c0;
}
}
section.log {
padding: 0 0.5em;
max-width: unset;
}
.log .domain {
display: grid;
grid-template-columns: auto auto;
gap: 0.5em;
place-content: end;
}
.log table {
border-collapse: collapse;
margin: 0 auto;
width: 100%;
background-color: var(--bg);
}
.log thead {
position: sticky;
top: 0;
}
.log thead th {
color: #fff;
background-color: #999;
padding: 0.5em 0.2em;
font-size: 0.9em;
font-weight: normal;
text-align: left;
}
.log thead th img {
width: 1em;
vertical-align: unset;
}
.log tr:hover {
background-color: var(--hover) !important;
}
.log tr:has(td[title*="407 Proxy"]) {
color: var(--mark);
}
.log tbody tr:nth-of-type(even) {
background-color: var(--tr);
}
.log td {
font-size: 0.8em;
padding: 0.2em;
}
.log td:nth-of-type(6),
.log td:nth-of-type(7),
.log td:nth-of-type(12) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 20em;
}
.log td:nth-of-type(7) {
max-width: 40em;
}
.log td:nth-of-type(8) {
border-left: 2px solid var(--border);
}
.log td:nth-of-type(12) {
max-width: 5em;
}
.log td.incognito::before {
content: '';
width: 1em;
height: 1em;
display: inline-block;
vertical-align: text-bottom;
background: url('../image/privateBrowsing.svg') no-repeat center / contain;
}
.log tbody {
counter-reset: n;
}
.log tbody tr td:first-child::before {
display: inline-block;
color: #aaa;
min-width: 1.5em;
text-align: right;
vertical-align: middle;
margin-right: 0.4em;
pointer-events: none;
counter-increment: n;
content: counter(n);
font-size: 0.8em;
}

View File

@@ -0,0 +1,146 @@
import {Flag} from './flag.js';
import {Pattern} from './pattern.js';
import {Popup} from './options-popup.js';
import {Nav} from './nav.js';
export class Log {
static {
this.trTemplate = document.querySelector('.log template').content.firstElementChild;
this.tbody = document.querySelector('.log tbody');
// used to find proxy
this.proxyCache = {};
this.mode = 'disable';
browser.webRequest.onBeforeRequest.addListener(e => this.process(e), {urls: ['<all_urls>']});
// onAuthRequired message from authentication.js
browser.runtime.onMessage.addListener((...e) => this.onMessage(...e));
// Get Associated Domains
this.input = document.querySelector('.log input');
document.querySelector('.log button').addEventListener('click', () => this.getDomains());
this.select = document.querySelector('.popup select.popup-log-proxy');
this.select.addEventListener('change', () => this.addPatterns());
}
static onMessage(message) {
const {id, e} = message;
if (id !== 'onAuthRequired') { return; }
const tr = this.tbody.children[199] || this.trTemplate.cloneNode(true);
const [, time, container, method, reqType, doc, url, title, type, host, port, pattern] = tr.children;
time.textContent = new Date(e.timeStamp).toLocaleTimeString();
container.classList.toggle('incognito', !!e.incognito);
container.textContent = e.cookieStoreId?.startsWith('firefox-container-') ? 'C' + e.cookieStoreId.substring(18) : '';
method.textContent = e.method;
reqType.textContent = e.statusCode;
this.prepareOverflow(doc, e.statusLine || '');
this.prepareOverflow(url, decodeURIComponent(e.url));
const info = e.challenger || {host: '', port: ''};
const item = this.proxyCache[`${info.host}:${info.port}`];
const flag = item?.cc ? Flag.get(item.cc) + ' ' : '';
title.textContent = flag + (item?.title || '');
title.style.borderLeftColor = item?.color || 'var(--border)';
type.textContent = item?.type || '';
host.textContent = info.host;
port.textContent = info.port;
pattern.textContent = '';
// in reverse order, new on top
this.tbody.prepend(tr);
}
static process(e) {
const tr = this.tbody.children[199] || this.trTemplate.cloneNode(true);
const [, time, container, method, reqType, doc, url, title, type, host, port, pattern] = tr.children;
// shortened forms similar to Developer Tools
const shortType = {
'main_frame': 'html',
'sub_frame': 'iframe',
image: 'img',
script: 'js',
stylesheet: 'css',
websocket: 'ws',
xmlhttprequest: 'xhr',
};
time.textContent = new Date(e.timeStamp).toLocaleTimeString();
container.classList.toggle('incognito', !!e.incognito);
container.textContent = e.cookieStoreId?.startsWith('firefox-container-') ? 'C' + e.cookieStoreId.substring(18) : '';
method.textContent = e.method;
reqType.textContent = shortType[e.type] || e.type;
// For a top-level document, documentUrl is undefined, chrome uses e.initiator
this.prepareOverflow(doc, e.documentUrl || e.initiator || '');
this.prepareOverflow(url, decodeURIComponent(e.url));
const info = e.proxyInfo || {host: '', port: '', type: ''};
const item = this.proxyCache[`${info.host}:${info.port}`];
const flag = item?.cc ? Flag.get(item.cc) + ' ' : '';
title.textContent = flag + (item?.title || '');
title.style.borderLeftColor = item?.color || 'var(--border)';
type.textContent = info.type;
host.textContent = info.host;
port.textContent = info.port;
// show matching pattern in pattern mode
const pat = this.mode === 'pattern' && item?.include.find(i => new RegExp(Pattern.get(i.pattern, i.type), 'i').test(e.url));
const text = pat?.title || pat?.pattern || '';
this.prepareOverflow(pattern, text);
// in reverse order, new on top
this.tbody.prepend(tr);
}
// set title, in case text overflows
static prepareOverflow(elem, value) {
elem.textContent = value;
elem.title = value;
}
static getDomains() {
this.select.classList.add('on');
const input = this.input.value.trim();
if (!input) {
// allow showing empty popup
Popup.show('');
return;
}
// search Document URL column
let list = document.querySelectorAll(`.log table td:nth-child(6)[title*="${input}" i]`);
list = [...list].map(i => i.nextElementSibling.title.split(/\/+/)[1]);
list = [...new Set(list)].sort();
// true -> show select
Popup.show(list.join('\n'));
}
static addPatterns() {
const host = this.select.value;
if (!host) { return; }
const text = this.select.previousElementSibling.value.trim();
if (!text) { return; }
const title = this.input.value.trim();
const data = text.split(/\s+/).map(i => ({
include: 'include',
type: 'wildcard',
title,
pattern: `://${i}/`,
active: true
}));
Popup.hide();
Nav.get('proxies');
const ev = new CustomEvent('importPatternCustom', {
detail: {host, data}
});
window.dispatchEvent(ev);
}
}

View File

@@ -0,0 +1,103 @@
// Using contextMenus namespace for compatibility with Chrome
// It's not possible to create tools menu items (contexts: ["tools_menu"]) using the contextMenus namespace.
import {App} from './app.js';
import {Proxy} from './proxy.js';
import {OnRequest} from './on-request.js';
import {Flag} from './flag.js';
// ---------- Context Menu ---------------------------------
export class Menus {
static {
// contextMenus is not supported on Android
browser.contextMenus?.onClicked.addListener((...e) => this.process(...e));
this.data = [];
}
static init(pref) {
// not available on Android
if (!browser.contextMenus) { return; }
this.pref = pref;
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/context_menus.json
// chrome.contextMenus not promise yet -> Uncaught TypeError: Cannot read properties of undefined (reading 'then')
browser.contextMenus.removeAll(() => this.addMenus(pref.data));
}
static addMenus(data) {
// not for PAC, limit to 10
this.data = data.filter(i => i.active && i.type !== 'pac').slice(0, 10);
if (!this.data[0]) { return; }
// --- create contextMenus
// https://searchfox.org/mozilla-central/source/browser/components/extensions/parent/ext-menus.js#756
// https://searchfox.org/mozilla-central/source/browser/components/extensions/parent/ext-menus.js#625-636
// contexts defaults to ['page'], 'all' is also added in Firefox but not in Chrome
// https://github.com/w3c/webextensions/issues/774
// Inconsistency: contextMenus/Menus
// child menu inherits parent's contexts but chrome has a problem with inheriting in "action" contextMenus
const {basic, firefox} = App;
const allowedPattern = !basic && !this.pref.managed;
const documentUrlPatterns = ['http://*/*', 'https://*/*'];
// menus.create requires an id for non-persistent background scripts.
this.contextMenus = [
...(allowedPattern ? [{id: 'includeHost', documentUrlPatterns}] : []),
...(allowedPattern ? [{id: 'excludeHost', documentUrlPatterns}] : []),
...(allowedPattern && firefox ? [{id: 'sep', type: 'separator', documentUrlPatterns}] : []),
...(firefox ? [{id: 'tabProxy'}] : []),
...(firefox ? [{parentId: 'tabProxy', id: 'tabProxy' + this.data.length, title: '\u00A0'}] : []),
...(firefox ? [{id: 'openLinkTabProxy', contexts: ['link']}] : []),
];
allowedPattern && this.addProxies('includeHost');
allowedPattern && this.addProxies('excludeHost');
firefox && this.addProxies('tabProxy');
firefox && this.addProxies('openLinkTabProxy');
this.contextMenus.forEach(i => {
// always use the same ID for i18n
i.type !== 'separator' && (i.title ||= browser.i18n.getMessage(i.id));
// add contexts
// !i.parentId && (i.contexts ||= ['all']);
i.contexts ||= ['all'];
browser.contextMenus.create(i);
});
}
static addProxies(parentId) {
this.data.forEach((i, index) =>
this.contextMenus.push({
parentId,
id: parentId + index,
title: Flag.get(i.cc) + ' ' + (i.title || `${i.hostname}:${i.port}`)
})
);
}
static async process(info, tab) {
const pref = this.pref;
const id = info.parentMenuItemId;
const index = info.menuItemId.substring(id.length);
const proxy = this.data[index];
switch (id) {
case 'includeHost':
case 'excludeHost':
Proxy.includeHost(pref, proxy, tab, id);
break;
// --- firefox only
case 'setTabProxy':
OnRequest.setTabProxy(tab, proxy);
break;
case 'openLinkTabProxy':
tab = await browser.tabs.create({});
OnRequest.setTabProxy(tab, proxy);
browser.tabs.update(tab.id, {url: info.linkUrl});
break;
}
}
}

View File

@@ -0,0 +1,303 @@
/*
----- Patterns -----
Chrome FoxyProxy v3 (old) featured full/partial url match pattern
Firefox FoxyProxy v4 - v7 (old) featured host only match pattern
- Migrating v4 - v7 storage data to full/partial url match pattern
- Dropping select for http|https|all
----- SOCKS keyword -----
https://developer.chrome.com/docs/extensions/reference/proxy/#proxy-rules
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/ProxyInfo
https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md#http-proxy-scheme
Chrome PAC schemes PROXY | HTTPS | SOCKS4/SOCKS | SOCKS5
Chrome API schemes http | https | socks4 | socks5 | quic
Firefox PAC schemes PROXY/HTTP | HTTPS | SOCKS4/SOCKS | SOCKS5
Firefox API types http | https | socks4 | socks (means socks5) | direct
Firefox/Chrome PAC 'SOCKS' means SOCKS4
Firefox API 'SOCKS' means SOCKS5
Code uses PAC 'SOCKS4/5' but converts to socks in Firefox API
----- host/hostname keywords -----
Firefox/Chrome API/PAC use 'host' for domain/ip
JavaScript uses 'hostname' for for domain/ip & 'host' for domain:port/ip:port
Code uses JavaScript 'hostname' domain/ip
*/
import {App} from './app.js';
import {Pattern} from './pattern.js';
import {Color} from './color.js';
/*
Chrome v3 (old) encrypts username/passwords using CryptoJS 3.1.2
CryptoJS library is used to migrate preferences to v8.0 but will be removed in future upgrades
Original CryptoJS 3.1.2 aes.js https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/aes.js
`export {CryptoJS};` was added to be able to import as ES6 module
*/
// removed in v9.0
// import {CryptoJS} from '../lib/aes.3.1.2.js';
export class Migrate {
static async init(pref) {
// --- 8.9
// 8.9 remove showPatternProxy (from 8.7)
// 8.8 tidy up left-over obj Sync typo mistake (from 8.0)
// 8.7 change global proxyDNS to per-proxy (from 8.0)
if (Object.hasOwn(pref, 'proxyDNS') && pref.data) {
pref.data.forEach(i => i.proxyDNS = !!pref.proxyDNS);
await browser.storage.local.set(pref);
}
// 8.1 remove globalExcludeWildcard, globalExcludeRegex (from 8.0)
const keys = ['showPatternProxy', 'obj', 'proxyDNS', 'globalExcludeWildcard', 'globalExcludeRegex'];
keys.forEach(i => delete pref[i]);
await browser.storage.local.remove(keys);
await browser.storage.sync.remove(keys);
// --- 8.0
if (pref.data) { return; }
let db = {};
switch (true) {
case !Object.keys(pref)[0]:
db = App.getDefaultPref();
break;
case Object.hasOwn(pref, 'settings'):
db = this.convert3(pref);
break;
default:
db = this.convert7(pref);
}
if (Object.keys(pref)[0]) {
// clear pref
Object.keys(pref).forEach(i => delete pref[i]);
await browser.storage.local.clear();
}
// populate pref
Object.keys(db).forEach(i => pref[i] = db[i]);
// --- update database
await browser.storage.local.set(pref);
// return pref;
}
// static decrypt(str, key) {
// return CryptoJS.AES.decrypt(str, key).toString(CryptoJS.enc.Utf8).split(/(?<!\\):/).map(i => i.replace(/\\:/g, ':'));
// }
// --- Chrome v3
static convert3(pref) {
// https://groups.google.com/a/chromium.org/g/chromium-extensions/c/6qiMo0P-XS4
// mode in v3 was saved to localStorage and not accessible in MV3 service worker
// new database format
const db = App.getDefaultPref();
db.sync = !!pref?.settings?.useSyncStorage;
// CryptoJS key
// const sk = pref.settings.sk;
pref.proxyList?.forEach(key => {
const item = pref[key].data;
// skip
if (key === 'default' || !item?.patterns) { return; }
// convert to actual type: http | https | socks4 | socks5 | + PAC
// default HTTP (no HTTPS option in FP Chrome v3)
const type = item.isSocks ? (item.socks === '5' ? 'socks5' : 'socks4') :
(item.type === 'auto' ? 'pac' : 'http');
// removed in v9.0
// decrypt username, password
// const [username = '', password = ''] = sk ? this.decrypt(item.credentials, sk) : [];
// proxy template
const pxy = {
active: item.enabled,
title: item.name || '',
type,
// rename to hostname
hostname: item.host,
port: item.port,
username: '',
password: '',
// remove country, use CC in location.js
cc: '',
city: '',
color: item.color || Color.getRandom(),
pac: type === 'pac' ? item.configUrl : '',
pacString: '',
proxyDNS: true,
include: [],
exclude: [],
tabProxy: [],
};
// process include/exclude
item.patterns.forEach(elem => {
const p = elem.data;
// skip
if (!p?.type) { return; }
const pat = {
active: true,
// v3 keep patterns as they are
pattern: p.url,
title: p.name || '',
// uses wildcard | regexp -> change to regex
type: p.type === 'wildcard' ? 'wildcard' : 'regex'
};
// Validate RegExp, deactivate on error
!Pattern.validate(pat.pattern, pat.type) && (pat.active = false);
// whitelist: Inclusive/Exclusive
p.whitelist === 'Inclusive' ? pxy.include.push(pat) : pxy.exclude.push(pat);
});
db.data.push(pxy);
});
return db;
}
// --- Firefox v6-7 (also used in Options -> Import Older Export)
static convert7(pref) {
const typeSet = {
1: 'http', // PROXY_TYPE_HTTP
2: 'https', // PROXY_TYPE_HTTPS
3: 'socks5', // PROXY_TYPE_SOCKS5
4: 'socks4', // PROXY_TYPE_SOCKS4
5: 'direct' // PROXY_TYPE_NONE
};
// new database format
pref.mode ||= 'disable';
// rename disabled -> disable
pref.mode === 'disabled' && (pref.mode = 'disable');
// rename patterns -> pattern
pref.mode === 'patterns' && (pref.mode = 'pattern');
// convert old mode
if (pref[pref.mode]) {
const i = pref[pref.mode];
pref.mode = `${i.address}:${i.port}`;
}
const db = App.getDefaultPref();
db.sync = !!pref.sync;
// null value causes an error in hasOwn, direct proxies don't have 'address'
const data = Object.values(pref).filter(i => i && ['address', 'type'].some(p => Object.hasOwn(i, p)));
// sort by index
data.sort((a, b) => a.index - b.index);
data.forEach(i => {
// proxy template
const pxy = {
// convert to boolean, some old databases have mixed types
active: i.active === 'true' || i.active === true,
title: i.title || '',
// convert to actual type: http | https | socks4 | socks5 | direct | + add PAC
type: typeSet[i.type],
// rename to hostname
hostname: i.address || '',
port: i.port || '',
username: i.username || '',
password: i.password || '',
// remove country, use CC in location.js
cc: i.cc || '',
city: '',
color: i.color || Color.getRandom(),
// add PAC option
pac: '',
pacString: '',
proxyDNS: !!i.proxyDNS,
// rename to include
include: i.whitePatterns || [],
// rename to exclude
exclude: i.blackPatterns || [],
tabProxy: [],
};
// convert UK to ISO 3166-1 GB
pxy.cc === 'UK' && (pxy.cc = 'GB');
// type 'direct'
pxy.type === 'direct' && (pxy.hostname = 'DIRECT');
/*
{
"active": true,
"pattern": "*.example.com",
"title": "example",
"type": 1,
// "protocols": 1,
},
*/
const patternSet = {
1: 'wildcard',
2: 'regex'
};
// process include/exclude
[...pxy.include, ...pxy.exclude].forEach(i => {
// convert to actual type: wildcard | regex
i.type = patternSet[i.type];
// convert wildcard all | http | https to v3 patterns
i.pattern = i.type === 'wildcard' ?
this.convertWildcard(i.pattern, i.protocols) : this.convertRegEx(i.pattern, i.protocols);
// no longer needed
delete i.protocols;
// Validate RegExp, deactivate on error
!Pattern.validate(i.pattern, i.type) && (i.active = false);
// convert v6-7 patterns to match pattern (v9.0)
if (i.type === 'wildcard' && Pattern.validMatchPattern(i.pattern + '*')) {
i.type === 'match';
i.pattern += '*';
}
});
db.data.push(pxy);
});
return db;
}
/*
.bbc.co.uk exact domain and all subdomains
*.bbc.co.uk exact domain and all subdomains
**.bbc.co.uk subdomains only (not bbc.co.uk)
*/
static convertWildcard(pat, protocol) {
const protocolSet = {
// all | http | https
1: '*://',
2: 'http://',
4: 'https://'
};
return protocolSet[protocol] + (pat.startsWith('.') ? '*' : '') + pat + '/';
}
static convertRegEx(pat, protocol) {
const protocolSet = {
// all | http | https
1: '.+://',
2: 'http://',
4: 'https://'
};
// remove start assertion
pat.startsWith('^') && (pat = pat.substring(1));
// remove end assertion
pat.endsWith('$') && (pat = pat.slice(0, -1));
return protocolSet[protocol] + pat + '/';
}
}

View File

@@ -0,0 +1,75 @@
/* ----- Navigation ----- */
input[name="nav"],
input[type="checkbox"].control {
display: none;
}
.nav {
background-color: var(--nav-bg);
}
nav {
display: grid;
grid-auto-flow: column;
justify-content: start;
align-items: end;
color: var(--nav-color);
height: var(--nav-height);
max-width: var(--max-width);
margin: 0 auto;
}
nav img {
width: 2em;
margin: 0.2em 0.5em;
}
nav > label {
padding: 0.5em 1em;
/* transition: 0.5s; */
border-radius: 0.5em 0.5em 0 0;
}
nav > label:hover {
background-color: var(--nav-hover);
}
/* nav > label img {
width: 1em;
}
nav label a {
color: inherit;
}
nav label a:hover {
text-decoration: none;
} */
#nav1:checked ~ article section:nth-of-type(1),
#nav2:checked ~ article section:nth-of-type(2),
#nav3:checked ~ article section:nth-of-type(3),
#nav4:checked ~ article section:nth-of-type(4),
#nav5:checked ~ article section:nth-of-type(5),
#nav6:checked ~ article section:nth-of-type(6),
#nav7:checked ~ article section:nth-of-type(7),
#nav8:checked ~ article section:nth-of-type(8) {
display: block;
/* animation: sect 0.5s ease-in-out; */
}
#nav1:checked ~ .nav label[for="nav1"],
#nav2:checked ~ .nav label[for="nav2"],
#nav3:checked ~ .nav label[for="nav3"],
#nav4:checked ~ .nav label[for="nav4"],
#nav5:checked ~ .nav label[for="nav5"],
#nav6:checked ~ .nav label[for="nav6"],
#nav7:checked ~ .nav label[for="nav7"],
#nav8:checked ~ .nav label[for="nav8"] {
background-color: var(--body-bg);
}
/* @keyframes sect {
0% { opacity: 0; }
100% { opacity: 1; }
} */

View File

@@ -0,0 +1,52 @@
import {App} from './app.js';
export class Nav {
static {
document.querySelectorAll('label[for^="nav"]').forEach(i =>
this[i.dataset.i18n] = i.control);
}
static get(pram = location.search.substring(1)) {
pram && this[pram] && (this[pram].checked = true);
}
static {
// --- openShortcutSettings FF137
const shortcut = document.querySelector('.shortcut-link');
// commands is not supported on Android
if (!App.firefox || browser.commands?.openShortcutSettings) {
shortcut.style.display = 'unset';
shortcut.addEventListener('click', () =>
App.firefox ? browser.commands?.openShortcutSettings() :
browser.tabs.create({url: 'chrome://extensions/shortcuts'})
);
}
// help document
const help = document.querySelector('iframe[src="help.html"]').contentDocument;
// --- data-link
const helpLink = help.querySelector('.nav-link');
document.querySelectorAll('[data-link]').forEach(i => i.addEventListener('click', e => {
const {link} = e.target.dataset;
if (!link) { return; }
Nav.get('help');
helpLink.href = link;
helpLink.click();
}));
// --- Extension link in the Help
// not for Firefox
// https://bugzilla.mozilla.org/show_bug.cgi?id=1956860
// chrome
if (!App.firefox) {
const link = help.querySelector('.chrome-extension');
link.style.display = 'unset';
link.addEventListener('click', () =>
browser.tabs.create({url: 'chrome://extensions/?id=' + location.hostname}));
}
}
}

View File

@@ -0,0 +1,305 @@
// https://bugs.chromium.org/p/chromium/issues/detail?id=1198822
// Dynamic import is not available yet in MV3 service worker
// Once implemented, module will be dynamically imported for Firefox only
// https://bugzilla.mozilla.org/show_bug.cgi?id=1853203
// Support non-ASCII username/password for socks proxy (fixed in Firefox 119)
// https://bugzilla.mozilla.org/show_bug.cgi?id=1741375
// Proxy DNS by default when using SOCKS v5 (fixed in Firefox 128, defaults to true for SOCKS5 & false for SOCKS4)
// https://bugzilla.mozilla.org/show_bug.cgi?id=1893670
// Proxy DNS by default for SOCK4 proxies. Defaulting to SOCKS4a
// proxyAuthorizationHeader on Firefox only applied to HTTPS (HTTP broke the API and sent DIRECT)
// https://bugzilla.mozilla.org/show_bug.cgi?id=1794464
// Allow HTTP authentication in proxy.onRequest (fixed in Firefox 125)
// proxy.onRequest only applies to http/https/ws/wss
// it can not catch domains set by user to 127.0.0.1 in the hosts file
import {App} from './app.js';
import {Pattern} from './pattern.js';
import {Location} from './location.js';
// ---------- Firefox proxy.onRequest API ------------------
export class OnRequest {
static {
// --- default values
this.mode = 'disable';
// used for Single Proxy
this.proxy = {};
// used for Proxy by Pattern
this.data = [];
// RegExp string
this.passthrough = [];
// [start, end] strings
this.net = [];
// tab proxy, will be lost in MV3 background unloading
this.tabProxy = {};
// incognito/container proxy
this.container = {};
// --- Firefox only
if (browser.proxy.onRequest) {
browser.proxy.onRequest.addListener(e => this.process(e), {urls: ['<all_urls>']});
// check Tab for tab proxy
browser.tabs.onUpdated.addListener((...e) => this.onUpdated(...e));
// remove redundant data from this.tabProxy cache
browser.tabs.onRemoved.addListener(tabId => delete this.tabProxy[tabId]);
// mark incognito/container
browser.tabs.onCreated.addListener(e => this.checkPageAction(e));
// prevent proxy.onRequest.addListener unloading in MV3 (default 30s)
// https://bugzilla.mozilla.org/show_bug.cgi?id=1771203
// it can trigger DNS leak on reloading under limited circumstances
// https://bugzilla.mozilla.org/show_bug.cgi?id=1882276
this.persist();
}
}
static persist() {
// clear the previous interval & set a new one
clearInterval(this.interval);
this.interval = setInterval(() => browser.runtime.getPlatformInfo(), 25_000);
}
static init(pref) {
this.mode = pref.mode;
[this.passthrough, , this.net] = Pattern.getPassthrough(pref.passthrough);
// filter data
const data = pref.data.filter(i => i.active && i.type !== 'pac' && i.hostname);
// --- single proxy (false|undefined|proxy object)
this.proxy = /:\d+[^/]*$/.test(pref.mode) && data.find(i => pref.mode === `${i.hostname}:${i.port}`);
// --- proxy by pattern
this.data = data.filter(i => i.include[0] || i.exclude[0] || i.tabProxy?.[0]).map(item => {
item.tabProxy ||= [];
return {
type: item.type,
hostname: item.hostname,
port: item.port,
username: item.username,
password: item.password,
// proxyDNS used in mode pattern or single proxy
proxyDNS: item.proxyDNS,
include: item.include.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
exclude: item.exclude.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
tabProxy: item.tabProxy.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
// used for showPatternProxy
title: item.title,
cc: item.cc,
city: item.city,
color: item.color,
};
});
// --- incognito/container proxy
// reset container
this.container = {};
pref.container && Object.entries(pref.container).forEach(([key, val]) => {
// prefix key
key.startsWith('container-') && (key = 'firefox-' + key);
this.container[key] = val && data.find(i => val === `${i.hostname}:${i.port}`);
});
// mirror as this.tabProxy is lost in MV3 background unloading
browser.storage.session.get('tabProxy')
.then(i => this.tabProxy = i.tabProxy || {});
}
static process(e) {
// reset interval
this.persist();
const tabId = e.tabId;
const fromTab = tabId !== -1;
// --- check Tab Proxy Pattern
this.processTabProxy(tabId, e.url, e);
switch (true) {
// --- check local & global passthrough
case this.bypass(e.url):
this.setAction(tabId);
return {type: 'direct'};
// --- tab proxy
case fromTab && !!this.tabProxy[tabId]:
return this.processProxy(tabId, this.tabProxy[tabId]);
// --- incognito proxy
case fromTab && e.incognito && !!this.container.incognito:
return this.processProxy(tabId, this.container.incognito);
// --- container proxy
case fromTab && e.cookieStoreId && !!this.container[e.cookieStoreId]:
return this.processProxy(tabId, this.container[e.cookieStoreId]);
// --- standard operation
// pass direct
case this.mode === 'disable':
case this.mode === 'direct':
// PAC URL is set
case this.mode.includes('://') && !/:\d+$/.test(this.mode):
this.setAction(tabId);
return {type: 'direct'};
// check if url matches patterns
case this.mode === 'pattern':
return this.processPattern(tabId, e.url);
// get the proxy for all
default:
return this.processProxy(tabId, this.proxy);
}
}
static processTabProxy(tabId, url, e) {
if (this.mode !== 'pattern' || e.type !== 'main_frame' || this.tabProxy[tabId]) { return; }
const match = arr => arr.some(i => new RegExp(i, 'i').test(url));
const proxy = this.data.find(i => match(i.tabProxy));
proxy && (this.tabProxy[tabId] = proxy);
}
static processPattern(tabId, url) {
const match = arr => arr.some(i => new RegExp(i, 'i').test(url));
const proxy = this.data.find(i => !match(i.exclude) && match(i.include));
if (proxy) {
return this.processProxy(tabId, proxy);
}
// no match
this.setAction(tabId);
return {type: 'direct'};
}
static processProxy(tabId, proxy) {
this.setAction(tabId, proxy);
const {type, hostname: host, port, username, password, proxyDNS} = proxy || {};
if (!type || type === 'direct') { return {type: 'direct'}; }
const auth = username && password && type.startsWith('http');
const response = {
host,
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#102
// Although API converts to number -> let port = Number.parseInt(proxyData.port, 10);
// port 'number', prepare for augmented port
port: parseInt(port),
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#43
// API uses socks for socks5
type: type === 'socks5' ? 'socks' : type,
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#135
...(type.startsWith('socks') && {proxyDNS: !!proxyDNS}),
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#117
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#126
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#231
// only used for SOCKS 4/5, must be string and not undefined
// allow sending username without password
username,
password,
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ProxyChannelFilter.sys.mjs#167
// only for HTTP/HTTPS
// use proxyAuthorizationHeader to reduce requests in webRequest.onAuthRequired
...(auth && {proxyAuthorizationHeader: 'Basic ' + btoa(username + ':' + password)}),
};
return response;
}
// browser.action here only relates to showPatternProxy from proxy.onRequest
static setAction(tabId, item) {
// Set to -1 if the request isn't related to a tab
if (tabId === -1) { return; }
// --- reset values
let title = '';
let text = '';
let color = '';
// --- set proxy details
if (item) {
const host = [item.hostname, item.port].filter(Boolean).join(':');
title = [item.title, host, item.city, Location.get(item.cc)].filter(Boolean).join('\n');
text = item.title || item.hostname;
color = item.color;
}
color && browser.action.setBadgeBackgroundColor({color, tabId});
browser.action.setTitle({title, tabId});
browser.action.setBadgeText({text, tabId});
}
// ---------- passthrough --------------------------------
// Firefox & Chrome have a default localhost bypass
// Connections to localhost, 127.0.0.1/8, and ::1 are never proxied.
// Firefox: "network.proxy.allow_hijacking_localhost"
// Chrome: --proxy-bypass-list="<-loopback>"
// https://bugzilla.mozilla.org/show_bug.cgi?id=1854324
// proxy.onRequest failure to bypass proxy for localhost (fixed in Firefox 137)
static bypass(url) {
switch (true) {
// global passthrough
case this.passthrough.some(i => new RegExp(i, 'i').test(url)):
// global passthrough CIDR
case this.net[0] && this.isInNet(url):
return true;
}
}
static isInNet(url) {
// check if IP address
if (!/^[a-z]+:\/\/\d+(\.\d+){3}(:\d+)?\//.test(url)) { return; }
// IP array
const ipa = url.split(/[:/.]+/, 5).slice(1);
// convert to padded string
const ip = ipa.map(i => i.padStart(3, '0')).join('');
return this.net.some(([st, end]) => ip >= st && ip <= end);
}
// ---------- Tab Proxy ----------------------------------
static setTabProxy(tab, pxy) {
switch (true) {
// unacceptable URLs
case !App.allowedTabProxy(tab.url):
// check global passthrough
case this.bypass(tab.url):
return;
}
// set or unset
pxy ? this.tabProxy[tab.id] = pxy : delete this.tabProxy[tab.id];
this.setAction(tab.id, pxy);
// mirror as this.tabProxy is lost in MV3 background unloading
browser.storage.session.set({'tabProxy': this.tabProxy});
}
// ---------- Update Page Action -------------------------
static onUpdated(tabId, changeInfo, tab) {
if (changeInfo.status !== 'complete') { return; }
const pxy = this.tabProxy[tabId];
pxy ? this.setAction(tab.id, pxy) : this.checkPageAction(tab);
}
// ---------- Incognito/Container ------------------------
static checkPageAction(tab) {
// not if tab proxy is set
if (tab.id === -1 || this.tabProxy[tab.id]) { return; }
const pxy = tab.incognito ? this.container.incognito : this.container[tab.cookieStoreId];
pxy && this.setAction(tab.id, pxy);
}
}

View File

@@ -0,0 +1,26 @@
// ---------- Filter Proxy (Side Effect) -------------------
class Filter {
static {
this.list = document.querySelector('div.proxy-div');
const filter = document.querySelector('.filter');
filter.addEventListener('input', e => this.process(e));
}
static process(e) {
const str = e.target.value.toLowerCase().trim();
const elem = [...this.list.children];
if (!str) {
elem.forEach(i => i.classList.remove('off'));
return;
}
elem.forEach(item => {
const proxyBox = item.children[1].children[0];
const title = proxyBox.children[1].value;
const hostname = proxyBox.children[3].value;
const port = ':' + proxyBox.children[7].value;
item.classList.toggle('off', ![title, hostname, port].some(i => i.toLowerCase().includes(str)));
});
}
}

View File

@@ -0,0 +1,55 @@
/* ----- Popup ----- */
.popup {
display: none;
background-color: #0003;
margin: 0;
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 10;
transition: all 0.5s ease-in-out;
}
.popup.on {
display: grid;
place-content: center;
animation: fade-in 0.5s ease-in-out;
}
.popup span {
background-color: var(--alt-bg);
display: grid;
place-content: center end;
padding: 0 0.8em;
cursor: pointer;
font-size: 0.9em;
border-radius: 0.5em 0.5em 0 0;
/* set height to maintain the same result in chrome/firefox */
height: 2em;
}
.popup span:hover {
background-color: var(--hover);
}
.popup textarea {
width: 50vw;
height: 50vh;
resize: both;
padding: 0.5em;
}
.popup select {
display: none;
}
.popup select.on {
display: unset;
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}

View File

@@ -0,0 +1,24 @@
export class Popup {
static {
this.popup = document.querySelector('.popup');
[this.close, this.textarea] = this.popup.children;
this.close.addEventListener('click', () => this.hide());
}
static show(text) {
this.textarea.value += text + '\n';
this.popup.classList.add('on');
}
static hide() {
this.popup.classList.remove('on');
this.textarea.value = '';
[...this.popup.children].forEach(i => {
if (i.nodeName === 'SELECT') {
i.selectedIndex = 0;
i.classList.remove('on');
}
});
}
}

View File

@@ -0,0 +1,413 @@
import {App} from './app.js';
import {ImportExport} from './import-export.js';
import {Pattern} from './pattern.js';
import {Flag} from './flag.js';
import {Color} from './color.js';
import {PAC} from './pac.js';
import {Toggle} from './toggle.js';
import {Tester} from './tester.js';
import {Log} from './log.js';
import {Nav} from './nav.js';
export class Proxies {
static {
this.proxyDiv = document.querySelector('div.proxy-div');
[this.proxyTemplate, this.patternTemplate] =
document.querySelector('.proxy-section template').content.children;
// firefox only, disabling Tab Proxy in the template for chrome
!App.firefox && (this.patternTemplate.children[1].lastElementChild.disabled = true);
// --- buttons
document.querySelector('.proxy-top button[data-i18n="add"]').addEventListener('click', e => {
// this.addProxy(null, e.ctrlKey)
const [pxy, title] = this.addProxy();
e.ctrlKey ? this.proxyDiv.prepend(pxy) : this.proxyDiv.append(pxy);
pxy.open = true;
pxy.draggable = false;
title.focus();
});
// used to find proxy
this.proxyCache = {};
// used to get the details for the log
Log.proxyCache = this.proxyCache;
// this.process();
// import pattern from log.js
window.addEventListener('importPatternCustom', (e) => this.importPatternCustom(e));
}
// pref references the same object in the memory and its value gets updated
static process(pref) {
Log.mode = pref.mode;
// reset
this.proxyDiv.textContent = '';
const docFrag = document.createDocumentFragment();
pref.data.forEach(i => docFrag.append(this.addProxy(i)));
this.proxyDiv.append(docFrag);
}
static addProxy(item, modifier) {
// --- details: make a blank proxy with all event listeners
const pxy = this.proxyTemplate.cloneNode(true);
const summary = pxy.children[0];
const proxyBox = pxy.children[1].children[0];
const patternBox = pxy.children[1].children[2];
// disable draggable when details is open
summary.addEventListener('click', () => pxy.draggable = pxy.open);
// --- summary
const [flag, sumTitle, active, dup, del, up, down] = summary.children;
dup.addEventListener('click', () => this.duplicateProxy(pxy));
del.addEventListener('click', () => confirm(browser.i18n.getMessage('deleteConfirm')) && pxy.remove());
up.addEventListener('click', () => pxy.previousElementSibling?.before(pxy));
down.addEventListener('click', () => pxy.nextElementSibling?.after(pxy));
// proxy data
const [title, hostname, type, port, cc, username, city, passwordSpan,
colorSpan, pacSpan, proxyDNS] = [...proxyBox.children].filter((e, i) => i % 2);
title.addEventListener('change', e => sumTitle.textContent = e.target.value);
const [pac, storeLocallyLabel, view] = pacSpan.children;
type.addEventListener('change', e => {
// use for show/hide elements
pxy.dataset.type = e.target.value;
const elem = e.target.selectedOptions[0];
const data = elem.dataset;
// --- server auto-fill helpers
switch (true) {
case ['flag', 'hostname', 'port'].some(i => data[i]):
sumTitle.textContent = elem.textContent;
title.value = elem.textContent;
data.flag && (flag.textContent = data.flag);
data.hostname && (hostname.value = data.hostname);
data.port && (port.value = data.port);
break;
default:
flag.textContent = Flag.get(cc.value);
}
});
cc.addEventListener('change', () => flag.textContent = Flag.get(cc.value));
Toggle.password(passwordSpan.children[1]);
// random color
const color = item?.color || Color.getRandom();
summary.style.borderLeftColor = color;
const [colorInput, colorButton] = colorSpan.children;
colorInput.value = color;
colorInput.addEventListener('change', e => summary.style.borderLeftColor = e.target.value);
colorButton.addEventListener('click', e => {
e.target.previousElementSibling.value = Color.getRandom();
summary.style.borderLeftColor = e.target.previousElementSibling.value;
});
pac.addEventListener('change', e => {
const {hostname: h, port: p} = App.parseURL(e.target.value);
if (!h) {
e.target.classList.add('invalid');
return;
}
hostname.value = h;
port.value = p;
type.value = 'pac';
title.value ||= 'PAC';
sumTitle.textContent ||= 'PAC';
});
// ---patterns
pxy.querySelector('button[data-i18n="add|title"]').addEventListener('click', e => {
const elem = this.addPattern();
e.ctrlKey ? patternBox.prepend(elem) : patternBox.append(elem);
elem.children[4].focus();
});
pxy.querySelector('input[type="file"]').addEventListener('change', e => this.importPattern(e, patternBox));
pxy.querySelector('button[data-i18n^="export"]').addEventListener('click', () =>
this.exportPattern(patternBox, title.value.trim() || hostname.value.trim()));
// --- from add button
if (!item) {
return [pxy, title];
}
// show/hide elements
pxy.dataset.type = item.type;
const id = item.type === 'pac' ? item.pac : `${item.hostname}:${item.port}`;
// cache to find later
this.proxyCache[id] = item;
// --- populate with data
const pxyTitle = item.title || id;
// --- summary
flag.textContent = Flag.show(item);
sumTitle.textContent = pxyTitle;
active.checked = item.active;
// proxy details
title.value = item.title;
hostname.value = item.hostname;
type.value = item.type;
port.value = item.port;
cc.value = item.cc;
// used in "Get Location"
cc.dataset.hostname = item.hostname;
username.value = item.username;
city.value = item.city;
// used in "Get Location"
city.dataset.hostname = item.hostname;
passwordSpan.children[0].value = item.password;
pac.value = item.pac;
storeLocallyLabel.children[0].checked = !!item.pacString;
view.addEventListener('click', () => PAC.view(pac.value.trim()));
proxyDNS.checked = item.proxyDNS;
// --- patterns
patternBox.dataset.host = `${item.hostname}:${item.port}`;
item.tabProxy ||= [];
item.include.forEach(i => patternBox.append(this.addPattern(i, 'include')));
item.exclude.forEach(i => patternBox.append(this.addPattern(i, 'exclude')));
item.tabProxy.forEach(i => patternBox.append(this.addPattern(i, 'tabProxy')));
return pxy;
}
static addPattern(item, inc) {
// --- make a blank pattern with all event listeners
const div = this.patternTemplate.cloneNode(true);
const [quickAdd, include, type, title, pattern, active, test, del] = div.children;
quickAdd.addEventListener('change', e => {
const opt = e.target.selectedOptions[0];
type.value = opt.dataset.type;
title.value = opt.textContent;
pattern.value = opt.value;
// reset select option
e.target.selectedIndex = 0;
});
test.addEventListener('click', () => {
Tester.select.value = type.value;
Tester.pattern.value = pattern.value;
Tester.target = pattern;
// reset pre DOM
Tester.pre.textContent = Tester.pre.textContent.trim();
// navigate to Tester tab
Nav.get('tester');
});
// del.addEventListener('click', () => confirm(browser.i18n.getMessage('deleteConfirm')) && div.remove());
del.addEventListener('click', () => div.remove());
if (item) {
include.value = inc;
type.value = item.type;
title.value = item.title;
pattern.value = item.pattern;
active.checked = item.active;
}
return div;
}
static async duplicateProxy(item) {
// generating a new proxy as cloneNode() removes event listeners
const pxy = await this.getProxyDetails(item);
if (!pxy) { return; }
item.after(this.addProxy(pxy));
// close orig proxy
item.open = false;
// open duplicated proxy
item.nextElementSibling.open = true;
}
// import pattern from log.js
static importPatternCustom(e) {
const {host, data} = e.detail;
if (!host || !data) { return; }
const patternBox = document.querySelector(`.pattern-box[data-host="${host}"]`);
if (!patternBox) { return; }
data.forEach(i => patternBox.append(this.addPattern(i, i.include)));
patternBox.closest('details').open = true;
patternBox.lastElementChild.children[4].focus();
}
static importPattern(e, patternBox) {
const file = e.target.files[0];
switch (true) {
case !file: App.notify(browser.i18n.getMessage('error'));
return;
// check file MIME type
case !['text/plain', 'application/json'].includes(file.type):
App.notify(browser.i18n.getMessage('fileTypeError'));
return;
}
ImportExport.fileReader(file, data => {
try { data = JSON.parse(data); }
catch {
// display the error
App.notify(browser.i18n.getMessage('fileParseError'));
return;
}
Array.isArray(data) && data.forEach(i => patternBox.append(this.addPattern(i, i.include)));
});
}
static exportPattern(patternBox, title = '') {
const arr = [...patternBox.children].map(item => {
const elem = item.children;
return {
include: elem[1].value,
type: elem[2].value,
title: elem[3].value.trim(),
pattern: elem[4].value.trim(),
active: elem[5].checked,
};
});
// no patterns to export
if (!arr[0]) { return; }
title &&= '_' + title;
const data = JSON.stringify(arr, null, 2);
const filename = `${browser.i18n.getMessage('pattern')}${title}_${new Date().toISOString().substring(0, 10)}.json`;
ImportExport.saveFile({data, filename, type: 'application/json'});
}
static async getProxyDetails(elem) {
// proxy template
const obj = {
active: true,
title: '',
type: 'http',
hostname: '',
port: '',
username: '',
password: '',
cc: '',
city: '',
color: '',
pac: '',
pacString: '',
proxyDNS: true,
include: [],
exclude: [],
tabProxy: [],
};
// --- populate values
elem.querySelectorAll('[data-id]').forEach(i => {
// reset
i.classList.remove('invalid');
Object.hasOwn(obj, i.dataset.id) && (obj[i.dataset.id] = i.type === 'checkbox' ? i.checked : i.value.trim());
});
// --- check type: http | https | socks4 | socks5 | quic | pac | direct
switch (true) {
// DIRECT
case obj.type === 'direct':
obj.hostname = 'DIRECT';
break;
// PAC
case obj.type === 'pac':
const {hostname, port} = App.parseURL(obj.pac);
if (!hostname) {
this.setInvalid(elem, 'pac');
// alert(browser.i18n.getMessage('pacUrlError'));
return;
}
obj.hostname = hostname;
obj.port = port;
break;
// http | https | socks4 | socks5 | quic
case !obj.hostname:
this.setInvalid(elem, 'hostname');
alert(browser.i18n.getMessage('hostnamePortError'));
return;
case !obj.port:
this.setInvalid(elem, 'port');
alert(browser.i18n.getMessage('hostnamePortError'));
return;
}
// --- title check
if (!obj.title) {
const id = obj.type === 'pac' ? obj.pac : `${obj.hostname}:${obj.port}`;
elem.children[0].children[1].textContent = id;
}
// --- check store locally for active PAC
if (obj.active && obj.pac) {
const storeLocally = elem.querySelector('.pac input[type="checkbox"]');
if (storeLocally.checked) {
const str = await PAC.get(obj.pac);
/function\s+FindProxyForURL\s*\(/.test(str) && (obj.pacString = str.trim());
}
}
// --- check & build patterns
const cache = [];
for (const item of elem.querySelectorAll('.pattern-box .pattern-row')) {
const [, inc, type, title, pattern, active] = item.children;
// reset
pattern.classList.remove('invalid');
const pat = {
type: type.value,
title: title.value.trim(),
pattern: pattern.value.trim(),
active: active.checked,
};
// --- test pattern
// blank pattern
if (!pat.pattern) { continue; }
if (!Pattern.validate(pat.pattern, pat.type, true)) {
// show Proxy tab
Nav.get('proxies');
const details = item.closest('details');
// open proxy
details.open = true;
pattern.classList.add('invalid');
pattern.scrollIntoView({behavior: 'smooth'});
return;
}
// check for duplicate
if (cache.includes(pat.pattern)) {
item.remove();
continue;
}
// cache to check for duplicates
cache.push(pat.pattern);
obj[inc.value].push(pat);
}
return obj;
}
static setInvalid(parent, id) {
parent.open = true;
const elem = parent.querySelector(`[data-id="${id}"]`);
elem.classList.add('invalid');
parent.scrollIntoView({behavior: 'smooth'});
}
}

View File

@@ -0,0 +1,71 @@
/* ----- show/hide elements ----- */
details.proxy[data-type="direct"] :is(
[data-i18n="port"], [data-id="port"],
[data-i18n="username"], [data-id="username"],
[data-i18n="password"], .password,
[data-i18n="country"], [data-id="cc"],
[data-i18n="city"], [data-id="city"],
[data-type="pac"], .pac) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
details.proxy[data-type="pac"] :is(
[data-i18n="port"], [data-id="port"],
[data-i18n="username"], [data-id="username"],
[data-i18n="password"], .password) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
details.proxy[data-type="pac"] :is(.pattern-head, .pattern-box) {
display: none;
}
details.proxy:not([data-type="pac"]) :is([data-type="pac"], .pac) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
details.proxy[data-type="socks4"] :is(
[data-i18n="username"], [data-id="username"],
[data-i18n="password"], .password) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
details.proxy:not([data-type="socks5"]) :is([data-i18n="proxyDNS"], [data-id="proxyDNS"]) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
/* --- Chrome/Firefox --- */
body.chrome .firefox,
body:not(.chrome) .chrome,
body.chrome details.proxy[data-type="socks5"] :is(
[data-i18n="username"], [data-id="username"],
[data-i18n="password"], .password) {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
caption.firefox {
color: var(--nav-color);
visibility: hidden;
}
body.chrome caption.firefox {
visibility: visible;
opacity: 1;
}
/* --- Basic --- */
.basic :is(.pattern-head, .pattern-box) {
display: none;
}

View File

@@ -0,0 +1,549 @@
@import 'default.css';
@import 'progress-bar.css';
@import 'spinner.css';
@import 'nav.css';
@import 'theme.css';
@import 'options-show.css';
@import 'options-popup.css';
@import 'toggle-switch.css';
@import 'tester.css';
@import 'log.css';
/* ----- Light Theme ----- */
:root {
--nav-height: 3rem;
--max-width: 75rem;
--pass: #080;
--fail: #f00;
}
/* ----- Dark Theme ----- */
@media screen and (prefers-color-scheme: dark) {
:root {
--pass: #0c0;
--fail: #f90;
}
}
/* ----- General ----- */
html {
scroll-padding-top: var(--nav-height);
}
body {
opacity: 0;
margin: 0;
overflow: hidden;
transition: opacity 0.5s;
font-size: initial;
}
body::after {
content: '';
background-image: url('../image/logo.svg');
background-repeat: no-repeat;
background-size: contain;
position: absolute;
bottom: 0.5em;
right: 0;
display: inline-block;
width: 128px;
height: 128px;
opacity: 0.3;
z-index: -1;
}
article {
padding: 1em;
}
section {
display: none;
margin: 0 auto;
max-width: var(--max-width);
height: calc(100vh - var(--nav-height) - 2rem);
overflow: auto;
}
iframe {
border: none;
margin: 0;
width: 100%;
height: 99%;
}
/* img {
vertical-align: text-bottom;
} */
label:has(input[type="checkbox"]) {
cursor: pointer;
}
label > input[type="checkbox"] {
margin-right: 0.5em;
}
/*
input[type="number"] {
width: 4em;
}
*/
textarea {
min-height: 6em;
resize: vertical;
}
fieldset {
background-color: var(--bg);
border-radius: 0.5em;
border: 0;
padding: 1.5em;
}
/* fieldset label img {
width: 1em;
} */
.description {
border-left: 2px solid #abc;
font-size: 0.9em;
font-style: italic;
margin-left: 0.5em;
margin-top: 0;
padding-left: 0.3em;
}
details {
padding: 0;
background-color: var(--alt-bg);
border-radius: 5px;
margin-bottom: 0.5em;
}
details > summary {
border-radius: 5px;
padding: 0.5em;
}
details > summary:hover,
details[open] > summary {
background-color: var(--hover);
}
summary ~ * {
opacity: 0;
transition: opacity 0.5s;
}
details[open] summary ~ * {
opacity: 1;
}
/* ----- Import/Export ----- */
input[type="file"] {
display: none;
}
/* ----- /Import/Export ----- */
/* ----- Submit Button ----- */
/* button[type="submit"] {
display: table;
color: #fff;
background-color: var(--btn-bg);
font-size:0.9em;
margin: 1em auto 0;
padding: 0.5em 5em;
border-radius: 5px;
}
button[type="submit"]:hover {
background-color: var(--btn-hover);
} */
/* ----- /Submit Button ----- */
/* ----- Button ----- */
button.bin,
button.test,
button.close {
width: 1.5em;
border: 0;
}
button.bin::before,
button.test::before {
content: '';
width: 1em;
height: 1em;
display: inline-block;
vertical-align: text-bottom;
background: url('../image/bin.svg') no-repeat center / contain;
}
button.test::before {
background: url('../image/beaker.svg') no-repeat center / contain;
}
button.close::before {
content: '❌';
}
.up:hover, .down:hover {
color: #080;
}
button.random {
font-size: 1.3em;
}
button.slim {
padding: 0.2em 1em;
min-width: unset;
}
/* ----- /Button ----- */
/* ----- data-link ----- */
[data-link] {
cursor: pointer;
}
[data-link]:hover {
text-decoration: underline;
}
[data-link]::before {
content: '#';
display: inline-block;
width: 0.8em;
margin-left: -0.8em;
opacity: 0.5;
visibility: hidden;
}
[data-link]:hover::before {
visibility: visible;
}
footer {
display: grid;
}
footer span[data-link] {
font-size: 0.9em;
place-self: end;
}
/* ----- /data-link ----- */
/* ----- Options ----- */
/*
input[type="text"],
input[type="password"],
input[type="url"],
select,
textarea {
font-size: 1em;
} */
section.options fieldset * {
transition: opacity 0.5s;
}
input[type="color"] {
border: 0;
width: 4em;
height: 1.7em;
}
.options .buttons {
display: grid;
grid-auto-flow: column;
justify-content: end;
column-gap: 0.2em;
margin-bottom: 1em;
}
.options .theme,
.options .container,
.options .commands {
display: grid;
grid-template-columns: minmax(10em, max-content) max-content;
gap: 0.2em 1em;
margin-bottom: 1em;
}
.options .container label,
.options .commands label {
margin-left: 1em;
}
.container button {
grid-column: span 2;
justify-self: right;
/* padding: 0.2em;
min-width: 6em; */
}
/* button strip */
/* .options .buttons {
padding: 0 1.5em;
margin: 0 -1.5em 1em;
background-color: var(--btn-bg);
}
.options .buttons > * {
border-left: 2px solid var(--bg);
border-radius: 0;
}
.options .buttons > *:last-child {
border-right: 2px solid var(--bg);
} */
/* ----- /Options ----- */
details .content {
padding: 0.5em;
}
/* ----- Proxy ----- */
.proxy-top {
display: grid;
grid-template-columns: auto 1fr auto auto auto;
gap: 0.2em;
margin-bottom: 0.5em;
}
.proxy-top input.filter {
background: url('../image/filter.svg') no-repeat left 0.5em center / 1em;
padding-left: 2em;
justify-self: start;
}
details.proxy .content * {
transition: opacity 0.5s;
}
details.proxy.off {
display: none;
}
details.proxy > summary {
list-style: none;
border-left: 1.5em solid var(--btn-bg);
display: grid;
grid-template-columns: auto 1fr repeat(5, auto);
gap: 0.2em;
align-items: center;
}
details.proxy > summary button.plain {
line-height: 1em;
}
details.proxy > summary span:first-of-type {
margin-right: 0.3em;
}
details.proxy > summary span:nth-of-type(2):empty::before {
content: 'title ...';
font-style: italic;
opacity: 0.2;
}
.proxy-box {
display: grid;
grid-template-columns: 1fr 1fr 1fr 2fr;
/* grid-auto-rows: min-content; */
gap: 0.5em;
padding-bottom: 0.5em;
}
.password {
display: grid;
grid-template-columns: 1fr 1em;
gap: 0.5em;
}
.proxy-box button.random {
line-height: 1em;
}
.pac {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.2em 0.5em;
font-size: 0.9em;
grid-row: span 2;
}
.pac [type="url"] {
grid-column: span 2;
}
.pac button {
/* padding: 0.2em 1em;
min-width: unset; */
place-self: center end;
}
/* ----- Pattern ----- */
.pattern-box {
padding: 0.2em;
max-height: 20em;
overflow-y: auto;
}
.pattern-head,
.pattern-row {
display: grid;
grid-template-columns: 3em 6em 6em 1fr 2fr 2em 1em 1em;
gap: 0.2em;
place-items: center;
font-size: 0.9em;
}
.pattern-head {
background-color: var(--bg);
padding: 0.2em;
}
.pattern-head button {
/* padding: 0.2em 1em;
min-width: unset; */
font-size: 0.8em;
}
/* .pattern-head button[data-i18n="add|title"] {
grid-column: span 3;
} */
.pattern-head button span.plus {
filter: brightness(0) invert(1);
}
.pattern-head :nth-child(2) {
grid-column: span 2;
}
/* import & export (label & button) */
.pattern-head .plain {
font-size: 1em;
line-height: 1em;
cursor: pointer;
}
.pattern-head :last-child {
grid-column: span 2;
}
/* hide when there are no patterns */
.pattern-head:has(+ .pattern-box:empty) :nth-child(n+3) {
opacity: 0;
}
.pattern-row {
margin-top: 0.2em;
padding: 0 0.4em;
}
/* .pattern-row input:nth-of-type(2) {
grid-column: span 3;
} */
.proxy summary button,
.pattern-box button.test,
.pattern-box button.bin {
padding: 0;
background-color: transparent;
/* border: none; */
font-size: 1em;
transition: 0.5s;
color: #ccc;
font-weight: 900;
text-shadow: 0 -1px 1px #555, 0 1px 0 #fff;
}
/* ----- /Pattern ----- */
/* ----- bulk edit ----- */
.bulk-edit {
display: grid;
grid-template-columns: repeat(3, 3.5em) 10em auto;
justify-content: end;
gap: 0.2em;
}
details.proxy.t1 {
outline: 1px solid red;
}
details.proxy:is(.t1, .s1) .pattern-row.t2 {
outline: 1px solid blue;
outline-offset: -1px;
}
details.proxy.s1 {
outline: 1px solid green;
}
.proxy-div:empty ~ .bulk-edit {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
/* ----- /bulk-edit ----- */
/* ----- Import ----- */
.import textarea {
height: 10em;
}
.import summary::marker {
color: var(--header);
}
.import details.import-account .content {
display: grid;
grid-template-columns: 1fr 3fr 1em;
gap: 0.5em;
}
.import details.import-account input#username {
grid-column: span 2;
}
.import .import-account .account-options {
display: grid;
grid-auto-flow: column;
grid-column: span 2;
justify-content: start;
gap: 0.5em;
}
.import details button.flat {
margin-top: 0.5em;
}
.import details.import-account button.flat {
justify-self: start;
}
/* ----- /import ----- */
/* ----- Counter ----- */
/* .proxy-section {
counter-reset: n;
}
.proxy-section details summary :first-child::before {
counter-increment: n;
content: counter(n);
display: inline-block;
margin-right: 0.5em;
color: var(--dim);
} */
/* ----- /Counter ----- */

View File

@@ -0,0 +1,781 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-i18n="options">FoxyProxy </title>
<link href="../image/icon.svg" rel="icon" type="image/png">
<link href="options.css" rel="stylesheet">
<script type="module" src="options.js"></script>
</head>
<body>
<!-- Navigation -->
<input type="radio" name="nav" id="nav1">
<input type="radio" name="nav" id="nav2">
<input type="radio" name="nav" id="nav3" checked>
<input type="radio" name="nav" id="nav4">
<input type="radio" name="nav" id="nav5">
<input type="radio" name="nav" id="nav6">
<input type="radio" name="nav" id="nav7">
<div class="nav">
<nav>
<img src="../image/icon.svg" alt="">
<label for="nav3" data-i18n="options"></label>
<label for="nav4" data-i18n="proxies"></label>
<label for="nav5" data-i18n="import"></label>
<label for="nav6" data-i18n="tester"></label>
<label for="nav7" data-i18n="log"></label>
<label for="nav1" data-i18n="help"></label>
<label for="nav2" data-i18n="about"></label>
</nav>
</div>
<div class="progress-bar"></div>
<article>
<!-- spinner -->
<div class="spinner"></div>
<!-- popup -->
<div class="popup">
<span></span>
<textarea autocomplete="off" spellcheck="false"></textarea>
<select class="popup-log-proxy" autocomplete="off">
<option value="" data-i18n="proxy" disabled selected></option>
</select>
<select class="popup-test-proxy" autocomplete="off">
<option value="" data-i18n="proxy" disabled selected></option>
</select>
<select class="popup-server" autocomplete="off">
<option value="" data-i18n="server" disabled selected></option>
<option value="https://api.ipify.org/">ipify.org</option>
<option value="https://checkip.amazonaws.com/">amazonaws.com (Amazon)</option>
<option value="https://icanhazip.com/">icanhazip.com (Cloudflare)</option>
<option value="https://ident.me/">ident.me</option>
<option value="https://www.l2.io/ip">l2.io</option>
<option value="https://www.trackip.net/ip">trackip.net</option>
<option value="https://myip.dnsomatic.com/">dnsomatic.com</option>
<hr>
<option value="https://getfoxyproxy.org/geoip/?raw=1">getfoxyproxy.org</option>
<option value="https://ipinfo.io/ip">ipinfo.io</option>
<!-- <option value="https://ifconfig.co/">ifconfig.co</option> -->
<option value="https://ipecho.net/plain">ipecho.net</option>
<option value="https://whatismyip.akamai.com/">akamai.com</option>
<option value="https://myexternalip.com/raw">myexternalip.com</option>
</select>
</div>
<!-- Help/About -->
<section><iframe src="help.html"></iframe></section>
<section><iframe src="about.html"></iframe></section>
<!-- user options -->
<section class="options">
<fieldset>
<div class="buttons">
<label class="flat" data-i18n="import"><input type="file" id="file" accept=".json"></label>
<button type="button" id="export" class="flat" data-i18n="export"></button>
<button type="button" class="flat" data-i18n="deleteBrowsingData" id="deleteBrowsingData"></button>
<button type="button" class="flat" data-i18n="restoreDefaults"></button>
</div>
<label data-i18n="enableSync"><input type="checkbox" id="sync"></label>
<p class="description" data-i18n="enableSyncDescription"></p>
<label data-i18n="autoBackup"><input type="checkbox" id="autoBackup"></label>
<p class="description" data-i18n="autoBackupDescription"></p>
<div class="theme" style="margin-bottom: 0;">
<label data-i18n="limitWebRTC" data-link="#limit-webrtc"></label>
<select id="limitWebRTC">
<option value="default" data-i18n="default"></option>
<option value="default_public_interface_only" data-i18n="publicInterfaceOnly"></option>
<option value="default_public_and_private_interfaces" data-i18n="publicPrivateInterfaces"></option>
<option value="disable_non_proxied_udp" data-i18n="disableNonProxied"></option>
<option value="proxy_only" data-i18n="proxyOnly"></option>
</select>
</div>
<p class="description" data-i18n="limitWebRTCDescription"></p>
<!-- <label class="firefox" data-i18n="showPatternProxy"><input type="checkbox" id="showPatternProxy"></label>
<p class="description firefox" data-i18n="showPatternProxyDescription"></p> -->
<div class="theme">
<label data-i18n="theme" data-link="#theme"></label>
<select id="theme">
<option value="" data-i18n="default"></option>
<option>moonlight</option>
<option>moonlight alt</option>
</select>
</div>
<label data-i18n="container" data-link="#container"></label>
<p class="description" data-i18n="containerDescription"></p>
<div class="container">
<label data-i18n="incognito"></label>
<select name="incognito">
<option value="">&nbsp;</option>
</select>
<label class="firefox"><span data-i18n="container"></span> 1</label>
<select class="firefox" name="container-1">
<option value="">&nbsp;</option>
</select>
<label class="firefox"><span data-i18n="container"></span> 2</label>
<select class="firefox" name="container-2">
<option value="">&nbsp;</option>
</select>
<button type="button" class="flat slim firefox" data-i18n="add"></button>
<!-- container template -->
<template>
<label class="firefox"><span data-i18n="container"></span> </label>
<select class="firefox" name="container-">
<option value="">&nbsp;</option>
</select>
</template>
</div>
<!-- openShortcutSettings Firefox 137 -->
<label data-i18n="shortcut" data-link="#keyboard-shortcut"></label> <button type="button" class="plain shortcut-link" style="margin-left: 0.5em; display: none;">🔗</button>
<p class="description" data-i18n="shortcutDescription"></p>
<div class="commands">
<label data-i18n="setProxy"></label>
<select name="setProxy">
<option value="">&nbsp;</option>
</select>
<label data-i18n="setTabProxy"></label>
<select name="setTabProxy">
<option value="">&nbsp;</option>
</select>
<label data-i18n="includeHost"></label>
<select name="includeHost">
<option value="">&nbsp;</option>
</select>
<label data-i18n="excludeHost"></label>
<select name="excludeHost">
<option value="">&nbsp;</option>
</select>
</div>
<label data-i18n="globalExclude" data-link="#global-exclude"></label>
<p class="description" data-i18n="globalExcludeDescription"></p>
<textarea id="passthrough"></textarea>
<button type="submit" data-i18n="saveOptions"></button>
</fieldset>
</section>
<!-- /user options -->
<!-- proxy -->
<section class="proxy-section">
<fieldset>
<div class="proxy-top">
<button type="button" class="flat" data-i18n="add"></button>
<input type="text" class="filter" autocomplete="off" spellcheck="false" placeholder="filter">
<button type="button" class="flat" data-i18n="ping">🛜 </button>
<button type="button" class="flat" data-i18n="test"></button>
<button type="button" class="flat" data-i18n="getLocation"></button>
</div>
<div class="proxy-div"></div>
<div class="bulk-edit">
<input type="number" autocomplete="off" min="1" step="1">
<input type="number" autocomplete="off" min="1" step="1">
<input type="number" autocomplete="off" min="1" step="1">
<input type="text" autocomplete="off" spellcheck="false">
<select autocomplete="off">
<option value="" disabled selected data-i18n="bulkEdit"></option>
<option value="openAll" data-i18n="openAll"></option>
<option value="closeAll" data-i18n="closeAll"></option>
<hr>
<option value="deleteProxy" data-i18n="deleteProxy"></option>
<option value="moveProxy" data-i18n="moveProxy"></option>
<option value="movePattern" data-i18n="movePattern"></option>
<hr>
<option value="setTitle" data-i18n="setTitle"></option>
<option value="setType" data-i18n="setType"></option>
<option value="setPort" data-i18n="setPort"></option>
<option value="setUsername" data-i18n="setUsername"></option>
<option value="setPassword" data-i18n="setPassword"></option>
</select>
</div>
<button type="submit" data-i18n="saveOptions"></button>
<footer>
<span data-i18n="help" data-link="#proxies"></span>
</footer>
</fieldset>
<!-- template -->
<template>
<details class="proxy" draggable="true">
<summary>
<span>🌎</span>
<span></span>
<input type="checkbox" class="toggle" data-id="active" checked>
<button type="button" class="plain" data-i18n="duplicate|title"></button>
<button type="button" class="bin" data-i18n="delete|title"></button>
<button type="button" class="up"></button>
<button type="button" class="down"></button>
</summary>
<div class="content">
<div class="proxy-box">
<label data-i18n="title"></label>
<input type="text" data-id="title" spellcheck="false" placeholder="title">
<label data-i18n="hostname"></label>
<input type="text" data-id="hostname" spellcheck="false" placeholder="1.2.3.4, www.example.com">
<label data-i18n="type"></label>
<select data-id="type">
<optgroup data-i18n="type|label">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
<option value="socks5">SOCKS5</option>
<option value="quic">QUIC</option>
<option value="pac" data-flag="🌎">PAC</option>
<option value="direct" data-hostname="DIRECT" data-flag="⮕">DIRECT</option>
</optgroup>
<optgroup data-i18n="server|label">
<!-- By default v2rayA will open 20170 (socks5), 20171 (http), 20172 (http with shunt rules) ports through the core -->
<option value="http" data-hostname="127.0.0.1" data-port="8080" data-flag="🖥️">Burp</option>
<option value="http" data-hostname="127.0.0.1" data-port="8118" data-flag="🖥️">Privoxy</option>
<option value="socks5" data-hostname="127.0.0.1" data-port="60351" data-flag="🖥️">Psiphon</option>
<option value="socks5" data-hostname="127.0.0.1" data-port="1080" data-flag="🖥️">Shadowsocks</option>
<option value="socks5" data-hostname="127.0.0.1" data-port="9050" data-flag="🖥️">TOR</option>
<option value="http" data-hostname="127.0.0.1" data-port="2081" data-flag="🖥️">NekoRay HTTP</option>
<option value="socks5" data-hostname="127.0.0.1" data-port="2080" data-flag="🖥️">NekoRay SOCKS5</option>
<option value="http" data-hostname="127.0.0.1" data-port="20171" data-flag="🖥️">V2RayA HTTP</option>
<option value="http" data-hostname="127.0.0.1" data-port="20172" data-flag="🖥️">V2RayA HTTP (with Rule)</option>
<option value="socks5" data-hostname="127.0.0.1" data-port="20170" data-flag="🖥️">V2RayA SOCKS5</option>
</optgroup>
</select>
<label data-i18n="port"></label>
<input type="text" data-id="port" placeholder="3128">
<label data-i18n="country"></label>
<select data-id="cc">
<option value="">&nbsp;</option>
<option value="AF">🇦🇫 Afghanistan</option>
<option value="AL">🇦🇱 Albania</option>
<option value="DZ">🇩🇿 Algeria</option>
<option value="AS">🇦🇸 American Samoa</option>
<option value="AD">🇦🇩 Andorra</option>
<option value="AO">🇦🇴 Angola</option>
<option value="AI">🇦🇮 Anguilla</option>
<option value="AQ">🇦🇶 Antarctica</option>
<option value="AG">🇦🇬 Antigua and Barbuda</option>
<option value="AR">🇦🇷 Argentina</option>
<option value="AM">🇦🇲 Armenia</option>
<option value="AW">🇦🇼 Aruba</option>
<option value="AU">🇦🇺 Australia</option>
<option value="AT">🇦🇹 Austria</option>
<option value="AZ">🇦🇿 Azerbaijan</option>
<option value="AX">🇦🇽 Åland Islands</option>
<option value="BS">🇧🇸 Bahamas</option>
<option value="BH">🇧🇭 Bahrain</option>
<option value="BD">🇧🇩 Bangladesh</option>
<option value="BB">🇧🇧 Barbados</option>
<option value="BY">🇧🇾 Belarus</option>
<option value="BE">🇧🇪 Belgium</option>
<option value="BZ">🇧🇿 Belize</option>
<option value="BJ">🇧🇯 Benin</option>
<option value="BM">🇧🇲 Bermuda</option>
<option value="BT">🇧🇹 Bhutan</option>
<option value="BO">🇧🇴 Bolivia, Plurinational State of</option>
<option value="BQ">🇧🇶 Bonaire, Sint Eustatius and Saba</option>
<option value="BA">🇧🇦 Bosnia and Herzegovina</option>
<option value="BW">🇧🇼 Botswana</option>
<option value="BV">🇧🇻 Bouvet Island</option>
<option value="BR">🇧🇷 Brazil</option>
<option value="IO">🇮🇴 British Indian Ocean Territory</option>
<option value="VG">🇻🇬 British Virgin Islands</option>
<option value="BN">🇧🇳 Brunei Darussalam</option>
<option value="BG">🇧🇬 Bulgaria</option>
<option value="BF">🇧🇫 Burkina Faso</option>
<option value="BI">🇧🇮 Burundi</option>
<option value="CV">🇨🇻 Cabo Verde</option>
<option value="KH">🇰🇭 Cambodia</option>
<option value="CM">🇨🇲 Cameroon</option>
<option value="CA">🇨🇦 Canada</option>
<option value="KY">🇰🇾 Cayman Islands</option>
<option value="CF">🇨🇫 Central African Republic</option>
<option value="TD">🇹🇩 Chad</option>
<option value="CL">🇨🇱 Chile</option>
<option value="CN">🇨🇳 China</option>
<option value="CX">🇨🇽 Christmas Island</option>
<option value="CC">🇨🇨 Cocos [Keeling] Islands</option>
<option value="CO">🇨🇴 Colombia</option>
<option value="KM">🇰🇲 Comoros</option>
<option value="CG">🇨🇬 Congo, Republic of the</option>
<option value="CD">🇨🇩 Congo, the Democratic Republic of the</option>
<option value="CK">🇨🇰 Cook Islands</option>
<option value="CR">🇨🇷 Costa Rica</option>
<option value="HR">🇭🇷 Croatia</option>
<option value="CU">🇨🇺 Cuba</option>
<option value="CW">🇨🇼 Curaçao</option>
<option value="CY">🇨🇾 Cyprus</option>
<option value="CZ">🇨🇿 Czechia</option>
<option value="TL">🇹🇱 Democratic Republic of Timor-Leste</option>
<option value="DK">🇩🇰 Denmark</option>
<option value="DJ">🇩🇯 Djibouti</option>
<option value="DM">🇩🇲 Dominica</option>
<option value="DO">🇩🇴 Dominican Republic</option>
<option value="EC">🇪🇨 Ecuador</option>
<option value="EG">🇪🇬 Egypt</option>
<option value="SV">🇸🇻 El Salvador</option>
<option value="GQ">🇬🇶 Equatorial Guinea</option>
<option value="ER">🇪🇷 Eritrea</option>
<option value="EE">🇪🇪 Estonia</option>
<option value="SZ">🇸🇿 Eswatini</option>
<option value="ET">🇪🇹 Ethiopia</option>
<option value="EU">🇪🇺 European Union</option>
<option value="FK">🇫🇰 Falkland Islands (Malvinas)</option>
<option value="FO">🇫🇴 Faroe Islands</option>
<option value="FJ">🇫🇯 Fiji</option>
<option value="FI">🇫🇮 Finland</option>
<option value="FR">🇫🇷 France</option>
<option value="GF">🇬🇫 French Guiana</option>
<option value="PF">🇵🇫 French Polynesia</option>
<option value="TF">🇹🇫 French Southern Territories</option>
<option value="GA">🇬🇦 Gabon</option>
<option value="GM">🇬🇲 Gambia</option>
<option value="GE">🇬🇪 Georgia</option>
<option value="DE">🇩🇪 Germany</option>
<option value="GH">🇬🇭 Ghana</option>
<option value="GI">🇬🇮 Gibraltar</option>
<option value="GR">🇬🇷 Greece</option>
<option value="GL">🇬🇱 Greenland</option>
<option value="GD">🇬🇩 Grenada</option>
<option value="GP">🇬🇵 Guadeloupe</option>
<option value="GU">🇬🇺 Guam</option>
<option value="GT">🇬🇹 Guatemala</option>
<option value="GG">🇬🇬 Guernsey</option>
<option value="GN">🇬🇳 Guinea</option>
<option value="GW">🇬🇼 Guinea-Bissau</option>
<option value="GY">🇬🇾 Guyana</option>
<option value="HT">🇭🇹 Haiti</option>
<option value="HM">🇭🇲 Heard Island and McDonald Islands</option>
<option value="HN">🇭🇳 Honduras</option>
<option value="HK">🇭🇰 Hong Kong</option>
<option value="HU">🇭🇺 Hungary</option>
<option value="IS">🇮🇸 Iceland</option>
<option value="IN">🇮🇳 India</option>
<option value="ID">🇮🇩 Indonesia</option>
<option value="IR">🇮🇷 Iran, Islamic Republic Of</option>
<option value="IQ">🇮🇶 Iraq</option>
<option value="IE">🇮🇪 Ireland</option>
<option value="IM">🇮🇲 Isle of Man</option>
<option value="IL">🇮🇱 Israel</option>
<option value="IT">🇮🇹 Italy</option>
<option value="CI">🇨🇮 Ivory Coast (Côte d'Ivoire)</option>
<option value="JM">🇯🇲 Jamaica</option>
<option value="JP">🇯🇵 Japan</option>
<option value="JE">🇯🇪 Jersey</option>
<option value="JO">🇯🇴 Jordan (Hashemite Kingdom of Jordan)</option>
<option value="KZ">🇰🇿 Kazakhstan</option>
<option value="KE">🇰🇪 Kenya</option>
<option value="KI">🇰🇮 Kiribati</option>
<option value="XK">🇽🇰 Kosovo</option>
<option value="KW">🇰🇼 Kuwait</option>
<option value="KG">🇰🇬 Kyrgyzstan</option>
<option value="LA">🇱🇦 Laos (Lao People's Democratic Republic)</option>
<option value="LV">🇱🇻 Latvia</option>
<option value="LB">🇱🇧 Lebanon</option>
<option value="LS">🇱🇸 Lesotho</option>
<option value="LR">🇱🇷 Liberia</option>
<option value="LY">🇱🇾 Libya</option>
<option value="LI">🇱🇮 Liechtenstein</option>
<option value="LT">🇱🇹 Lithuania</option>
<option value="LU">🇱🇺 Luxembourg</option>
<option value="MO">🇲🇴 Macao</option>
<option value="MG">🇲🇬 Madagascar</option>
<option value="MW">🇲🇼 Malawi</option>
<option value="MY">🇲🇾 Malaysia</option>
<option value="MV">🇲🇻 Maldives</option>
<option value="ML">🇲🇱 Mali</option>
<option value="MT">🇲🇹 Malta</option>
<option value="MH">🇲🇭 Marshall Islands</option>
<option value="MQ">🇲🇶 Martinique</option>
<option value="MR">🇲🇷 Mauritania</option>
<option value="MU">🇲🇺 Mauritius</option>
<option value="YT">🇾🇹 Mayotte</option>
<option value="MX">🇲🇽 Mexico</option>
<option value="FM">🇫🇲 Micronesia, Federated States of</option>
<option value="MD">🇲🇩 Moldova, Republic of</option>
<option value="MC">🇲🇨 Monaco</option>
<option value="MN">🇲🇳 Mongolia</option>
<option value="ME">🇲🇪 Montenegro</option>
<option value="MS">🇲🇸 Montserrat</option>
<option value="MA">🇲🇦 Morocco</option>
<option value="MZ">🇲🇿 Mozambique</option>
<option value="MM">🇲🇲 Myanmar</option>
<option value="NA">🇳🇦 Namibia</option>
<option value="NR">🇳🇷 Nauru</option>
<option value="NP">🇳🇵 Nepal</option>
<option value="NL">🇳🇱 Netherlands</option>
<option value="NC">🇳🇨 New Caledonia</option>
<option value="NZ">🇳🇿 New Zealand</option>
<option value="NI">🇳🇮 Nicaragua</option>
<option value="NE">🇳🇪 Niger</option>
<option value="NG">🇳🇬 Nigeria</option>
<option value="NU">🇳🇺 Niue</option>
<option value="NF">🇳🇫 Norfolk Island</option>
<option value="KP">🇰🇵 North Korea</option>
<option value="MK">🇲🇰 North Macedonia</option>
<option value="MP">🇲🇵 Northern Mariana Islands</option>
<option value="NO">🇳🇴 Norway</option>
<option value="OM">🇴🇲 Oman</option>
<option value="PK">🇵🇰 Pakistan</option>
<option value="PW">🇵🇼 Palau</option>
<option value="PS">🇵🇸 Palestine</option>
<option value="PA">🇵🇦 Panama</option>
<option value="PG">🇵🇬 Papua New Guinea</option>
<option value="PY">🇵🇾 Paraguay</option>
<option value="PE">🇵🇪 Peru</option>
<option value="PH">🇵🇭 Philippines</option>
<option value="PN">🇵🇳 Pitcairn Islands</option>
<option value="PL">🇵🇱 Poland</option>
<option value="PT">🇵🇹 Portugal</option>
<option value="PR">🇵🇷 Puerto Rico</option>
<option value="QA">🇶🇦 Qatar</option>
<option value="LT">🇱🇹 Republic of Lithuania</option>
<option value="RO">🇷🇴 Romania</option>
<option value="RU">🇷🇺 Russia (Russian Federation)</option>
<option value="RW">🇷🇼 Rwanda</option>
<option value="RE">🇷🇪 Réunion</option>
<option value="BL">🇧🇱 Saint Barthélemy</option>
<option value="SH">🇸🇭 Saint Helena</option>
<option value="LC">🇱🇨 Saint Lucia</option>
<option value="MF">🇲🇫 Saint Martin (French part)</option>
<option value="PM">🇵🇲 Saint Pierre and Miquelon</option>
<option value="VC">🇻🇨 Saint Vincent and the Grenadines</option>
<option value="WS">🇼🇸 Samoa</option>
<option value="SM">🇸🇲 San Marino</option>
<option value="SA">🇸🇦 Saudi Arabia</option>
<option value="SN">🇸🇳 Senegal</option>
<option value="RS">🇷🇸 Serbia</option>
<option value="SC">🇸🇨 Seychelles</option>
<option value="SL">🇸🇱 Sierra Leone</option>
<option value="SG">🇸🇬 Singapore</option>
<option value="SX">🇸🇽 Sint Maarten (Dutch part)</option>
<option value="SK">🇸🇰 Slovakia</option>
<option value="SI">🇸🇮 Slovenia</option>
<option value="SB">🇸🇧 Solomon Islands</option>
<option value="SO">🇸🇴 Somalia</option>
<option value="ZA">🇿🇦 South Africa</option>
<option value="GS">🇬🇸 South Georgia and the South Sandwich Islands</option>
<option value="KR">🇰🇷 South Korea</option>
<option value="SS">🇸🇸 South Sudan</option>
<option value="ES">🇪🇸 Spain</option>
<option value="LK">🇱🇰 Sri Lanka</option>
<option value="KN">🇰🇳 St Kitts and Nevis</option>
<option value="SD">🇸🇩 Sudan</option>
<option value="SR">🇸🇷 Suriname</option>
<option value="SJ">🇸🇯 Svalbard and Jan Mayen</option>
<option value="SE">🇸🇪 Sweden</option>
<option value="CH">🇨🇭 Switzerland</option>
<option value="SY">🇸🇾 Syria</option>
<option value="ST">🇸🇹 São Tomé and Príncipe</option>
<option value="TW">🇹🇼 Taiwan</option>
<option value="TJ">🇹🇯 Tajikistan</option>
<option value="TZ">🇹🇿 Tanzania</option>
<option value="TH">🇹🇭 Thailand</option>
<option value="TG">🇹🇬 Togo</option>
<option value="TK">🇹🇰 Tokelau</option>
<option value="TO">🇹🇴 Tonga</option>
<option value="TT">🇹🇹 Trinidad and Tobago</option>
<option value="TN">🇹🇳 Tunisia</option>
<option value="TM">🇹🇲 Turkmenistan</option>
<option value="TC">🇹🇨 Turks and Caicos Islands</option>
<option value="TV">🇹🇻 Tuvalu</option>
<option value="TR">🇹🇷 Türkiye</option>
<option value="UM">🇺🇲 U.S. Minor Outlying Islands</option>
<option value="VI">🇻🇮 U.S. Virgin Islands</option>
<option value="UG">🇺🇬 Uganda</option>
<option value="UA">🇺🇦 Ukraine</option>
<option value="AE">🇦🇪 United Arab Emirates</option>
<option value="GB">🇬🇧 United Kingdom</option>
<option value="US">🇺🇸 United States of America</option>
<option value="UY">🇺🇾 Uruguay</option>
<option value="UZ">🇺🇿 Uzbekistan</option>
<option value="VU">🇻🇺 Vanuatu</option>
<option value="VA">🇻🇦 Vatican City</option>
<option value="VE">🇻🇪 Venezuela</option>
<option value="VN">🇻🇳 Vietnam</option>
<option value="WF">🇼🇫 Wallis and Futuna</option>
<option value="EH">🇪🇭 Western Sahara</option>
<option value="YE">🇾🇪 Yemen</option>
<option value="ZM">🇿🇲 Zambia</option>
<option value="ZW">🇿🇼 Zimbabwe</option>
</select>
<label data-i18n="username"></label>
<input type="text" data-id="username" spellcheck="false" placeholder="username">
<label data-i18n="city"></label>
<input type="text" data-id="city" spellcheck="false" placeholder="city">
<label data-i18n="password"></label>
<span class="password">
<input type="password" data-id="password" spellcheck="false" placeholder="****">
<button type="button" class="plain" data-i18n="togglePassword|title">👁</button>
</span>
<label data-i18n="color"></label>
<span>
<input type="color" data-id="color" value="#ff9900">
<button type="button" class="plain random" data-i18n="random|title"></button>
</span>
<label data-type="pac">PAC URL</label>
<span class="pac">
<input type="url" data-id="pac" data-type="pac" placeholder="PAC URL">
<label class="pointer chrome" data-i18n="storeLocally"><input type="checkbox"></label>
<button type="button" class="flat slim" data-i18n="view"></button>
</span>
<label class="firefox" data-i18n="proxyDNS"></label>
<input type="checkbox" class="toggle firefox" data-id="proxyDNS" checked>
</div>
<div class="pattern-head">
<button type="button" class="flat slim" data-i18n="add|title"><span class="plus"></span></button>
<span data-i18n="proxyByPatterns"></span>
<span data-i18n="title"></span>
<span data-i18n="pattern"></span>
<label class="plain" data-i18n="import|title"><input type="file" accept=".json">📥</label>
<button type="button" class="plain" data-i18n="export|title">📤</button>
</div>
<div class="pattern-box"></div>
</div>
</details>
<div class="pattern-row">
<select>
<option value="" disabled selected>&nbsp;</option>
<option data-type="wildcard" value="*" data-i18n="all"></option>
<option data-type="wildcard" value="http://">HTTP</option>
<option data-type="wildcard" value="https://">HTTPS</option>
<option data-type="regex" value="^(http|ws)s?://[^.]+/" data-i18n="plainHost"></option>
<option data-type="regex" value="^(http|ws)s?://10(\.\d+){3}/">10.*.*.*</option>
<option data-type="regex" value="^(http|ws)s?://127(\.\d+){3}/">127.*.*.*</option>
<option data-type="regex" value="^(http|ws)s?://172\.16(\.\d+){2}/">172.16.*.*</option>
<option data-type="regex" value="^(http|ws)s?://192\.168(\.\d+){2}/">192.168.*.*</option>
</select>
<select>
<option value="include" data-i18n="include"></option>
<option value="exclude" data-i18n="exclude"></option>
<option value="tabProxy" data-i18n="tabProxy"></option>
</select>
<select>
<option value="wildcard" data-i18n="wildcard"></option>
<option value="match">Match</option>
<option value="regex">Reg Exp</option>
</select>
<input type="text" spellcheck="false" placeholder="title">
<input type="text" spellcheck="false" placeholder="://example.com/">
<input type="checkbox" class="toggle" checked>
<button type="button" class="test" data-i18n="test|title"></button>
<button type="button" class="bin" data-i18n="delete|title"></button>
</div>
</template>
<!-- /template -->
</section>
<!-- /proxy -->
<!-- import -->
<section class="import">
<fieldset>
<details class="import-account">
<summary data-i18n="importFoxyProxyAccount"></summary>
<div class="content">
<label data-i18n="username"></label>
<input id="username" type="text" spellcheck="false" placeholder="username">
<label data-i18n="password"></label>
<input id="password" type="password" spellcheck="false" placeholder="*****">
<button type="button" class="plain" data-i18n="togglePassword|title">👁</button>
<label data-i18n="options"></label>
<div class="account-options">
<select>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
<select>
<option value="hostname" data-i18n="hostname"></option>
<option value="ip">IP</option>
</select>
<select>
<option value="main" data-i18n="mainServer"></option>
<option value="alt" data-i18n="altServer"></option>
</select>
</div>
<button type="button" class="flat" data-i18n="import"></button>
</div>
</details>
<details class="import-from-url">
<summary data-i18n="importFromURL"></summary>
<div class="content">
<input type="url" placeholder="https://example.com/settings.json">
<button type="button" class="flat" data-i18n="import"></button>
</div>
</details>
<details class="import-proxy-list">
<summary data-i18n="importProxyList"></summary>
<div class="content">
<textarea></textarea>
<button type="button" class="flat" data-i18n="import"></button>
</div>
</details>
<details class="import-from-older">
<summary data-i18n="importFromOlderVersions"></summary>
<div class="content">
<label class="flat" data-i18n="import"><input type="file" accept=".json, .xml"></label>
</div>
</details>
<footer>
<span data-i18n="help" data-link="#import"></span>
</footer>
<!-- no need for submit SAVE button as all imports forward to Proxies Tab -->
<!-- <button type="submit" data-i18n="saveOptions"></button> -->
</fieldset>
</section>
<!-- /import -->
<!-- tester -->
<section class="tester">
<fieldset>
<h3 data-i18n="pattern" data-link="#pattern-guide"></h3>
<p class="description" data-i18n="testerDescription"></p>
<div class="tester-pattern">
<select>
<option value="wildcard" data-i18n="wildcard"></option>
<option value="match">Match</option>
<option value="regex">Reg Exp</option>
</select>
<input type="text" spellcheck="false" value="*://*.example.com/">
</div>
<pre contenteditable="true">https://example.com/
https://example.org/
http://help.example.com/
https://help.example.com/abc
https://google.com/ref=help.example.com</pre>
<div class="buttons">
<button type="button" class="flat" data-i18n="test"></button>
<button type="button" class="flat" data-i18n="back"></button>
</div>
<h3 data-i18n="proxyByPatterns"></h3>
<p class="description" data-i18n="proxyByPatternsDescription"></p>
<input type="url" spellcheck="false" pattern="https?://.*" placeholder="https://example.com/abc">
<pre></pre>
<button type="button" class="flat proxyByPatterns" data-i18n="test"></button>
<footer>
<span data-i18n="help" data-link="#pattern-tester"></span>
</footer>
</fieldset>
</section>
<!-- /tester -->
<!-- log -->
<section class="log">
<div class="domain">
<input type="text" autocomplete="off" spellcheck="false" placeholder="example.com">
<button type="button" class="flat slim" data-i18n="getAssociatedDomains"></button>
</div>
<table>
<caption class="firefox" data-i18n="notAvailable"></caption>
<thead>
<tr>
<th style="width: 1.5em;"></th>
<th style="width: 5em;" data-i18n="time"></th>
<th style="width: 1em;"><img src="../image/container.svg" alt=""></th>
<th style="width: 3em;" data-i18n="method"></th>
<th style="width: 3em;" data-i18n="type"></th>
<th data-i18n="documentURL" data-link="#log"></th>
<th data-i18n="url" data-link="#log"></th>
<th><span data-i18n="proxy"></span> <span data-i18n="title"></span></th>
<th style="width: 3em;" data-i18n="type"></th>
<th data-i18n="hostname"></th>
<th style="width: 3em;" data-i18n="port"></th>
<th data-i18n="pattern"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<!-- template -->
<template>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</template>
</section>
<!-- /log -->
</article>
</body>
</html>

View File

@@ -0,0 +1,302 @@
import {pref, App} from './app.js';
import {Proxies} from './options-proxies.js';
import {ImportExport} from './import-export.js';
import {ImportUrl} from './import-url.js';
import {ImportOlder} from './import-older.js';
import {Flag} from './flag.js';
import {ProgressBar} from './progress-bar.js';
import {Nav} from './nav.js';
import './get-location.js';
import './incognito-access.js';
import './browsing-data.js';
import './webrtc.js';
import './options-filter.js';
import './bulk-edit.js';
import './drag-drop.js';
import './ping.js';
import './test.js';
import './import-account.js';
import './import-list.js';
import './show.js';
import './i18n.js';
import './theme.js';
// ---------- User Preferences -----------------------------
await App.getPref();
// ---------- Options --------------------------------------
class Options {
static {
// --- keyboard Shortcut
this.commands = document.querySelectorAll('.options .commands select');
// --- global passthrough
this.passthrough = document.getElementById('passthrough');
// --- buttons
document.querySelector('.options button[data-i18n="restoreDefaults"]').addEventListener('click', () => this.restoreDefaults());
this.init(['sync', 'autoBackup', 'theme', 'showPatternProxy', 'passthrough']);
}
static init(keys = Object.keys(pref)) {
// defaults to pref keys
this.prefNode = document.querySelectorAll('#' + keys.join(',#'));
// submit button
document.querySelectorAll('button[type="submit"]').forEach(i => i.addEventListener('click', () => this.check()));
this.process();
}
static process(save) {
// 'save' is only set when clicking the button to save options
this.prefNode.forEach(node => {
// value: 'select-one', 'textarea', 'text', 'number'
const attr = node.type === 'checkbox' ? 'checked' : 'value';
save ? pref[node.id] = node[attr] : node[attr] = pref[node.id];
});
// update saved pref
save && !ProgressBar.show() && browser.storage.local.set(pref);
this.fillContainerCommands(save);
}
static async check() {
// not for storage.managed
if (pref.managed) { return; }
// --- global exclude, clean up, remove path, remove duplicates
const passthrough = this.passthrough.value.trim();
const [separator] = passthrough.match(/[\s,;]+/) || ['\n'];
const arr = passthrough.split(/[\s,;]+/).filter(Boolean)
.map(i => /[\d.]+\/\d+/.test(i) ? i : i.replace(/(?<=[a-z\d])\/[^\s,;]*/gi, ''));
this.passthrough.value = [...new Set(arr)].join(separator);
pref.passthrough = this.passthrough.value;
// --- check and build proxies & patterns
const data = [];
const cache = {};
// using for loop to be able to break early
for (const item of document.querySelectorAll('div.proxy-div details')) {
const pxy = await Proxies.getProxyDetails(item);
if (!pxy) { return; }
data.push(pxy);
// cache to update Proxies cache
const id = pxy.type === 'pac' ? pxy.pac : `${pxy.hostname}:${pxy.port}`;
cache[id] = pxy;
}
// no errors, update pref.data
pref.data = data;
// helper: remove if proxy is deleted or disabled
const checkSelect = i => i.value && !cache[i.value]?.active && (i.value = '');
// --- container proxy
const containerList = document.querySelectorAll('.options .container select');
const container = {};
containerList.forEach(i => {
checkSelect(i);
i.value && (container[i.name] = i.value);
});
// set to pref
pref.container = container;
// --- keyboard shortcut proxy
const commands = {};
this.commands.forEach(i => {
checkSelect(i);
commands[i.name] = i.value;
});
// set to pref
pref.commands = commands;
// --- check mode
// get from storage in case it was changed while options page has been open
let {mode} = await browser.storage.local.get({mode: 'disable'});
switch (true) {
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode) && !pref.data.some(i => i.active && i.type === 'pac' && mode === i.pac):
case pref.mode.includes(':') && !pref.data.some(i => i.active && i.type !== 'pac' && mode === `${i.hostname}:${i.port}`):
case pref.mode === 'pattern' && !pref.data.some(i => i.active && i.include[0]):
mode = 'disable';
break;
}
pref.mode = mode;
// --- save options
this.process(true);
// --- update Proxy
// check 'prefers-color-scheme' since it is not available in background service worker
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
browser.runtime.sendMessage({id: 'setProxy', pref, dark});
// --- Auto Backup
pref.autoBackup && ImportExport.export(pref, false, `${browser.i18n.getMessage('extensionName')}/`);
// --- Sync
this.sync(pref);
}
// https://github.com/w3c/webextensions/issues/510
// Proposal: Increase maximum item size in Storage sync quotas
static sync(pref) {
if (!pref.sync) { return; }
// convert array to object {...data} to avoid sync maximum item size limit
const obj = {...pref.data};
// add other sync properties
App.syncProperties.forEach(i => obj[i] = pref[i]);
// save changes to sync
browser.storage.sync.set(obj)
.then(() => {
// delete left-over proxies
browser.storage.sync.get()
.then(syncObj => {
// get & delete numerical keys that are equal or larger than data length, the rest are overwritten
const del = Object.keys(syncObj).filter(i => /^\d+$/.test(i) && i * 1 >= pref.data.length);
del[0] && browser.storage.sync.remove(del);
});
})
.catch(error => {
App.notify(browser.i18n.getMessage('syncError') + '\n\n' + error.message);
// disabling sync option to avoid repeated errors
document.getElementById('sync').checked = false;
browser.storage.local.set({sync: false});
});
}
static restoreDefaults() {
if (!confirm(browser.i18n.getMessage('restoreDefaultsConfirm'))) { return; }
const db = App.getDefaultPref();
Object.keys(db).forEach(i => pref[i] = db[i]);
this.process();
Proxies.process();
}
static makeProxyOption() {
// create proxy option
const docFrag = document.createDocumentFragment();
// filter out PAC, limit to 50
pref.data.filter(i => i.active && i.type !== 'pac').slice(0, 50).forEach(i => {
const flag = Flag.get(i.cc);
const value = `${i.hostname}:${i.port}`;
const opt = new Option(flag + ' ' + (i.title || value), value);
// supported on Chrome, not on Firefox
// opt.style.color = item.color;
docFrag.append(opt.cloneNode(true));
});
return docFrag;
}
// --- container & commands
static fillContainerCommands(save) {
// create proxy option
const docFrag = this.makeProxyOption();
// not when clicking save
if (!save) {
this.addCustomContainer();
// populate the template select
this.containerSelect.append(docFrag.cloneNode(true));
// add custom containers, sort by number
const list = [...document.querySelectorAll('.options .container select')].map(i => i.name);
Object.keys(pref.container).filter(i => !list.includes(i)).sort()
.forEach(i => this.addContainer(i.substring(10)));
}
const containerList = document.querySelectorAll('.options .container select');
// reset
this.clearSelect(containerList);
this.clearSelect(this.commands);
containerList.forEach(i => {
i.append(docFrag.cloneNode(true));
pref.container[i.name] && (i.value = pref.container[i.name]);
});
this.commands.forEach(i => {
i.append(docFrag.cloneNode(true));
pref.commands[i.name] && (i.value = pref.commands[i.name]);
});
// help fill log select elements
document.querySelectorAll('.popup select:not(.popup-server)').forEach(i => i.append(docFrag.cloneNode(true)));
}
static clearSelect(elem) {
// remove children except the first one
elem.forEach(i => i.replaceChildren(i.firstElementChild));
}
static addCustomContainer() {
// using generic names
// https://bugzilla.mozilla.org/show_bug.cgi?id=1386673
// Make Contextual Identity extensions be an optional permission
// https://bugzilla.mozilla.org/show_bug.cgi?id=1947602
// Allow limited read-only access to contextualIdentities.query without permission
[this.containerLabel, this.containerSelect] =
document.querySelector('.options .container template').content.children;
this.containerButton = document.querySelector('.options .container button');
this.containerButton.addEventListener('click', () => this.addContainer(prompt(browser.i18n.getMessage('addContainerPrompt'))));
}
static addContainer(n) {
n *= 1;
if (!n || this.hasContainer(n)) { return; }
const label = this.containerLabel.cloneNode(true);
const select = this.containerSelect.cloneNode(true);
label.append(n);
select.name = `container-${n}`;
this.containerButton.before(label, select);
}
static hasContainer(n) {
return document.querySelector(`.options .container select[name="container-${n}"]`);
}
}
// ---------- /Options -------------------------------------
// ---------- Proxies --------------------------------------
Proxies.process(pref);
// ---------- Import From URL ------------------------------
ImportUrl.init(pref, () => {
// set options after the pref update, update page display, show Proxy tab
Options.process();
Proxies.process(pref);
Nav.get('proxies');
});
// ---------- Import Older Preferences ---------------------
ImportOlder.init(pref, () => {
// set options after the pref update, update page display, show Proxy tab
Options.process();
Proxies.process(pref);
Nav.get('proxies');
});
// ---------- Import/Export Preferences --------------------
ImportExport.init(pref, () => {
// set options after the pref update, update page display
Options.process();
Proxies.process(pref);
});
// ---------- Navigation -----------------------------------
Nav.get();

View File

@@ -0,0 +1,21 @@
import {Spinner} from './spinner.js';
import {Popup} from './options-popup.js';
export class PAC {
static async view(url) {
if (!url) { return; }
const text = await this.get(url);
Popup.show(text);
}
static async get(url) {
Spinner.show();
const text = await fetch(url)
.then(response => response.text())
.catch(error => error);
Spinner.hide();
return text;
}
}

View File

@@ -0,0 +1,165 @@
export class Pattern {
// showError from options.js, not from migrate.js
static validate(str, type, showError) {
// --- match pattern
if (type === 'match') {
if (this.validMatchPattern(str)) { return true; }
// not valid
if (showError) {
const error = this.checkMatchPattern(str);
error && alert([browser.i18n.getMessage('regexError'), str, error].join('\n'));
}
return;
}
// --- wildcard & regex
const pat = this.get(str, type);
try {
new RegExp(pat);
return true;
}
catch (error) {
showError && alert([browser.i18n.getMessage('regexError'), str, error].join('\n'));
}
}
static get(str, type) {
return type === 'wildcard' ? this.convertWildcard(str) :
type === 'match' ? this.convertMatchPattern(str) : str;
}
// convert wildcard to regex string
static convertWildcard(str) {
// catch all
if (str === '*') { return '\\w+'; }
// no need to add scheme as search parameters are encoded url=https%3A%2F%2F
// escape regular expression special characters, minus * ?
return str.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/^\*|\*$/g, '') // trim start/end *
.replaceAll('*', '.*')
.replaceAll('?', '.');
}
// convert match pattern to regex string
static convertMatchPattern(str) {
// catch all
if (str === '<all_urls>') { return '\\w+'; }
// escape regular expression special characters, minus *
str = str.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace('*://', '.+://') // convert * scheme
.replace('://*\\.', '://(.+\\.)?') // match domains & subdomains
.replaceAll('*', '.*');
// match pattern matches the whole URL
return '^' + str + '$';
}
static checkMatchPattern(str) {
// catch all
if (str === '<all_urls>') { return; }
const [, scheme, host] = str.match(/^(.+):\/\/([^/]+)\/(.*)$/) || [];
switch (true) {
case !scheme || !host:
// Invalid Pattern
return browser.i18n.getMessage('invalidPatternError');
case !['*', 'http', 'https', 'ws', 'wss'].includes(scheme):
// "*" in scheme must be the only character | Unsupported scheme
const msg = scheme.includes('*') ? 'schemeError' : 'unsupportedSchemeError';
return browser.i18n.getMessage(msg);
case host.substring(1).includes('*'):
// "*" in host must be at the start
return browser.i18n.getMessage('hostError');
case host.startsWith('*') && !host.startsWith('*.'):
// "*" in host must be the only character or be followed by "."
return browser.i18n.getMessage('hostDotError');
case host.includes(':'):
// Host must not include a port number
return browser.i18n.getMessage('portError');
}
}
// --- test match pattern validity
static validMatchPattern(p) {
// file: is not valid for proxying purpose
return p === '<all_urls>' ||
/^(https?|\*):\/\/(\*|\*\.[^*:/]+|[^*:/]+)\/.*$/i.test(p);
}
static getPassthrough(str) {
if (!str) { return [[], [], []]; }
// RegExp string
const regex = [];
// 10.0.0.0/24 -> [ip, mask] e.g ['10.0.0.0', '255.255.255.0']
const ipMask = [];
// 10.0.0.0/24 -> [start, end] e.g. ['010000000000', '010000000255']
const stEnd = [];
str.split(/[\s,;]+/).forEach(i => {
// The literal string <local> matches simple hostnames (no dots)
if (i === '<local>') {
regex.push('.+://[^.]+/');
return;
}
// --- CIDR
const [, ip, , mask] = i.match(/^(\d+(\.\d+){3})\/(\d+)$/) || [];
if (ip && mask) {
const netmask = this.getNetmask(mask);
ipMask.push(ip, netmask);
stEnd.push(this.getRange(ip, netmask));
return;
}
// --- pattern
i = i.replaceAll('.', '\\.') // literal '.'
.replaceAll('*', '.*'); // wildcard
// starting with '.'
i.startsWith('\\.') && (i = '.+://.+' + i);
// add scheme
!i.includes('://') && (i = '.+://' + i);
// add start assertion
// !i.startsWith('^') && (i = '^' + i);
// add pathname
i += '/';
regex.push(i);
});
return [regex, ipMask, stEnd];
}
// ---------- CIDR ---------------------------------------
// convert mask to netmask
static getNetmask(mask) {
return [...Array(4)].map(() => {
const n = Math.min(mask, 8);
mask -= n;
return 256 - Math.pow(2, 8 - n);
}).join('.');
}
// convert to padded start & end
static getRange(ip, mask) {
// ip array
let st = ip.split('.');
// mask array
const ma = mask.split('.');
// netmask wildcard array
let end = st.map((v, i) => Math.min(v - ma[i] + 255, 255) + '');
st = st.map(i => i.padStart(3, '0')).join('');
end = end.map(i => i.padStart(3, '0')).join('');
return [st, end];
}
}

View File

@@ -0,0 +1,45 @@
import {Popup} from './options-popup.js';
// ---------- Ping (Side Effect) ---------------------------
class Ping {
static {
document.querySelector('.proxy-top button[data-i18n="ping"]').addEventListener('click', () => this.process());
}
static async process() {
let {data} = await browser.storage.local.get({data: []});
data = data.filter(i => i.active);
if (!data[0]) { return; }
// --- text formatting
const n = 4;
const pType = Math.max(...data.map(i => i.type.length)) + n;
const pHost = Math.max(...data.map(i =>
(i.title || i.pac || `${i.hostname}:${parseInt(i.port)}`).length)) + n;
// performance.now() Firefox 280160 | Chrome 447156.4000000004
const format = n => new Intl.NumberFormat().format(n.toFixed()).padStart(8, ' ');
const dash = '--- --'.padStart(11, ' ');
data.forEach(i => {
const t = performance.now();
const host = `${i.hostname}:${parseInt(i.port)}`;
const url = i.pac || (i.type.startsWith('http') ? `${i.type}://${host}/` : `http://${host}/`);
const target = i.type.padEnd(pType, ' ') + (i.title || i.pac || host).padEnd(pHost, ' ');
if (['direct'].includes(i.type)) {
Popup.show(`${target}${dash} ${i.type}`);
return;
}
// Chrome a network request timeouts at 300 seconds, while in Firefox at 90 seconds.
// AbortSignal.timeout FF100, Ch124
fetch(url, {method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000)})
.then(r => {
const st = ![200, 400].includes(r.status) ? ` ${r.status} ${r.statusText}` : '';
Popup.show(`${target}${format(performance.now() - t)} ms${st}`);
})
.catch(e => Popup.show(`${target}${dash} ${e.message}`));
});
}
}

View File

@@ -0,0 +1,24 @@
// ---------- Filter Proxy (Side Effect) -------------------
class Filter {
static {
this.list = document.querySelector('div.list');
const filter = document.querySelector('.filter');
filter.addEventListener('input', e => this.filterProxy(e));
}
static filterProxy(e) {
const str = e.target.value.toLowerCase().trim();
const elem = [...this.list.children].slice(2); // not the first 2
if (!str) {
elem.forEach(i => i.classList.remove('off'));
return;
}
elem.forEach(item => {
const title = item.children[1].textContent;
const host = item.children[3].value; // input radio
item.classList.toggle('off', ![title, host].some(i => i.toLowerCase().includes(str)));
});
}
}

View File

@@ -0,0 +1,205 @@
@import 'default.css';
@import 'theme.css';
/* ----- Light Theme ----- */
:root {
--filter: opacity(0.4) grayscale(1);
--selected: #fec8;
}
/* for the default theme */
:root:not([class]) {
--nav-bg: #630;
}
/* ----- Dark Theme ----- */
@media screen and (prefers-color-scheme: dark) {
:root {
--filter: opacity(1) grayscale(1);
--selected: #222;
}
}
/* ----- General ----- */
body {
opacity: 0;
font-size: 12px;
width: 25em;
background-color: var(--bg);
transition: opacity 0.5s;
}
/*
https://bugzilla.mozilla.org/show_bug.cgi?id=1883896
Remove UA styles for :is(article, aside, nav, section) h1 (Nightly only) Firefox 125
https://bugzilla.mozilla.org/show_bug.cgi?id=1885509
Remove UA styles for :is(article, aside, nav, section) h1 (staged rollout)
*/
h1 {
color: var(--nav-color);
background-color: var(--nav-bg);
margin: 0;
padding: 0.5em;
font-size: 1.2em;
}
h1 img {
width: 1.5em;
vertical-align: text-bottom;
}
/* ----- Buttons ----- */
div.popup-buttons {
display: grid;
grid-auto-flow: column;
column-gap: 0.1em;
}
button {
color: #fff;
border: none;
padding: 0.8em;
/* font-weight: bold; */
}
button:hover {
background-color: var(--btn-hover);
}
/* ----- /Buttons ----- */
/* ----- Main Display ----- */
div.list {
padding-top: 0.5em;
min-height: 15em;
max-height: 30em;
overflow-y: auto;
scrollbar-width: thin;
}
div.list label {
display: grid;
grid-template-columns: 2.5em auto 1fr;
column-gap: 0.5em;
padding: 0.2em 0.5em;
cursor: pointer;
}
div.list label:hover {
background-color: var(--hover);
}
div.list label.off {
display: none;
}
div.list label:has(input[name="server"]:checked) {
background-color: var(--selected);
}
.flag img {
width: 1.2em;
}
.flag img.off {
filter: var(--filter);
}
.flag {
grid-row: span 2;
font-size: 1.8em;
line-height: 1em;
place-self: start center;
}
.title {
color: var(--header);
font-size: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.port {
color: var(--dim);
place-self: end start;
}
.data {
grid-column: span 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
color: var(--dim);
}
.data.off {
display: none;
}
input[name="server"] {
display: none;
}
/* --- more section --- */
summary {
background-color: var(--alt-bg);
padding: 0.2em 0.5em;
margin-bottom: 0.1em;
}
div.host {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0.5em;
gap: 0.3em 0.5em;
align-items: center;
}
div.host input.filter {
background: url('../image/filter.svg') no-repeat left 0.5em center/1em;
padding-left: 2em;
}
div.host button {
background-color: unset;
border-radius: 5px;
border: 1px solid var(--border);
color: var(--color);
font-weight: normal;
padding: 0.2em;
}
div.host button:hover {
background-color: var(--hover);
}
/* ----- show/hide elements ----- */
/* --- Chrome --- */
.chrome .firefox {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
/* ---managed --- */
.managed .local {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
/* --- Basic --- */
.basic .not-basic {
display: none;
}
/* --- scheme --- */
.not-http select.http,
.not-tab-proxy .tab-proxy {
opacity: 0.3;
pointer-events: none;
user-select: none;
}
/* ----- /show/hide elements ----- */

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popup</title>
<link href="popup.css" rel="stylesheet">
<script type="module" src="popup.js"></script>
</head>
<body>
<article>
<section>
<h1 data-i18n="extensionName"><img src="../image/icon.svg" alt=""> </h1>
<div class="list">
<label class="pattern not-basic">
<span class="flag"><img src="../image/power.svg" alt=""></span>
<span class="title" data-i18n="proxyByPatterns"></span>
<input type="radio" name="server" value="pattern">
</label>
<label class="disable">
<span class="flag"><img class="off" src="../image/power.svg" alt=""></span>
<span class="title" data-i18n="disable"></span>
<input type="radio" name="server" value="disable" checked>
</label>
</div>
<details>
<summary data-i18n="more"></summary>
<div class="host">
<input type="text" class="filter" autocomplete="off" spellcheck="false" placeholder="filter">
<select id="tabProxy" class="firefox tab-proxy">
<option value="" data-i18n="tabProxy" disabled selected></option>
<option value="">&nbsp;</option>
</select>
<select id="includeHost" class="local http not-basic">
<option value="" data-i18n="includeHost" disabled selected></option>
</select>
<select id="excludeHost" class="local http not-basic">
<option value="" data-i18n="excludeHost" disabled selected></option>
</select>
</div>
</details>
<div class="popup-buttons">
<button type="button" data-i18n="options"></button>
<button type="button" data-i18n="log"></button>
<button type="button" data-i18n="ip"></button>
<button type="button" data-i18n="location"></button>
</div>
<!-- template -->
<template>
<label>
<span class="flag"></span>
<span class="title"></span>
<span class="port"></span>
<input type="radio" name="server">
<span class="data"></span>
</label>
</template>
<!-- /template -->
</section>
</article>
</body>
</html>

View File

@@ -0,0 +1,170 @@
import {pref, App} from './app.js';
import {Location} from './location.js';
import {Flag} from './flag.js';
import './popup-filter.js';
import './show.js';
import './i18n.js';
// ---------- User Preferences -----------------------------
await App.getPref();
// ---------- Popup ----------------------------------------
class Popup {
static {
// --- theme
pref.theme && (document.documentElement.className = pref.theme);
// show after
document.body.style.opacity = 1;
document.querySelectorAll('button').forEach(i => i.addEventListener('click', e => this.processButtons(e)));
this.list = document.querySelector('div.list');
// --- Include/Exclude Host (not for storage.managed)
this.includeHost = document.querySelector('select#includeHost');
!pref.managed && this.includeHost.addEventListener('change', e => this.includeExclude(e));
this.excludeHost = document.querySelector('select#excludeHost');
!pref.managed && this.excludeHost.addEventListener('change', e => this.includeExclude(e));
// --- Tab Proxy (firefox only)
this.tabProxy = document.querySelector('select#tabProxy');
App.firefox && this.tabProxy.addEventListener('change', e => {
if (!this.tab) { return; }
const {value, selectedOptions} = e.target;
const proxy = value && this.proxyCache[selectedOptions[0].dataset.index];
browser.runtime.sendMessage({id: 'setTabProxy', proxy, tab: this.tab});
});
// disable buttons on storage.managed
pref.managed && document.body.classList.add('managed');
// --- store details open toggle
const details = document.querySelector('details');
// defaults to true
details.open = localStorage.getItem('more') !== 'false';
details.addEventListener('toggle', () => localStorage.setItem('more', details.open));
this.process();
}
static includeExclude(e) {
const {id, value} = e.target;
if (!value) { return; }
// proxy object reference to pref is lost in chrome when sent from popup.js
browser.runtime.sendMessage({id, pref, host: value, tab: this.tab});
// reset select option
e.target.selectedIndex = 0;
}
static checkProxyByPatterns() {
// check if there are patterns
if (!pref.data.some(i => i.active && (i.include[0] || i.tabProxy?.[0]))) {
// hide option if there are no patterns
this.list.children[0].style.display = 'none';
// show as disable
pref.mode === 'pattern' && (pref.mode = 'disable');
}
pref.mode === 'pattern' && (this.list.children[0].children[2].checked = true);
}
static async process() {
this.checkProxyByPatterns();
const labelTemplate = document.querySelector('template').content.firstElementChild;
const docFrag = document.createDocumentFragment();
pref.data.filter(i => i.active).forEach(i => {
const id = i.type === 'pac' ? i.pac : `${i.hostname}:${i.port}`;
const label = labelTemplate.cloneNode(true);
const [flag, title, port, radio, data] = label.children;
flag.textContent = Flag.show(i);
title.textContent = i.title || i.hostname;
port.textContent = !i.title ? i.port : '';
radio.value = i.type === 'direct' ? 'direct' : id;
radio.checked = id === pref.mode;
data.textContent = [i.city, Location.get(i.cc)].filter(Boolean).join(', ') || ' ';
docFrag.append(label);
});
this.list.append(docFrag);
this.list.addEventListener('click', e =>
// fires twice (click & label -> input)
e.target.name === 'server' && this.processSelect(e.target.value, e)
);
// --- Add Hosts to select
// used to find proxy, filter out PAC, limit to 10
this.proxyCache = pref.data.filter(i => i.active && i.type !== 'pac').slice(0, 10);
this.proxyCache.forEach((i, index) => {
const flag = Flag.show(i);
const value = `${i.hostname}:${i.port}`;
const opt = new Option(flag + ' ' + (i.title || value), value);
opt.dataset.index = index;
// supported on Chrome, not on Firefox
// opt.style.color = item.color;
docFrag.append(opt);
});
this.includeHost.append(docFrag.cloneNode(true));
this.excludeHost.append(docFrag.cloneNode(true));
this.tabProxy.append(docFrag);
// get active tab
[this.tab] = await browser.tabs.query({currentWindow: true, active: true});
// --- show/hide selects
document.body.classList.toggle('not-http', !this.tab.url.startsWith('http'));
// Check Tab proxy (Firefox only)
const allowedTabProxy = App.firefox && App.allowedTabProxy(this.tab.url);
allowedTabProxy && this.checkTabProxy();
document.body.classList.toggle('not-tab-proxy', !allowedTabProxy);
}
static checkTabProxy() {
browser.runtime.sendMessage({id: 'getTabProxy', tab: this.tab})
.then(i => i && (this.tabProxy.value = `${i.hostname}:${i.port}`));
}
static processSelect(mode, e) {
// disregard re-click
if (mode === pref.mode) { return; }
// not for storage.managed
if (pref.managed) { return; }
// check 'prefers-color-scheme' since it is not available in background service worker
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// save mode
pref.mode = mode;
browser.storage.local.set({mode});
browser.runtime.sendMessage({id: 'setProxy', pref, dark, noDataChange: true});
}
static processButtons(e) {
switch (e.target.dataset.i18n) {
case 'options':
browser.runtime.openOptionsPage();
break;
case 'location':
browser.tabs.create({url: 'https://getfoxyproxy.org/geoip/'});
break;
case 'ip':
// sending message to the background script to complete even if popup gets closed
browser.runtime.sendMessage({id: 'getIP'});
break;
case 'log':
browser.tabs.create({url: '/content/options.html?log'});
break;
}
window.close();
}
}

View File

@@ -0,0 +1,22 @@
/* ----- Progress Bar ----- */
.progress-bar {
opacity: 0;
height: 2px;
width: 0;
background: #3bb3e0 linear-gradient(124deg, #ff2400, #e81d1d, #e8b71d, #e3e81d, #1de840, #1ddde8, #2b1de8, #dd00f3, #dd00f3);
background-size: 900% 900%;
transition: opacity 1s ease-in-out, width 0s ease-in-out 1s;
animation: rainbow 3s ease infinite;
}
.progress-bar.on {
width: 100%;
opacity: 1;
transition: opacity 1s ease-in-out, width 2s ease-in-out;
}
@keyframes rainbow {
0% { background-position: 0% 100%; }
50% { background-position: 100% 200%; }
100% { background-position: 0% 100%; }
}

View File

@@ -0,0 +1,10 @@
// ---------- Progress Bar ---------------------------------
export class ProgressBar {
static bar = document.querySelector('.progress-bar');
static show() {
this.bar.classList.toggle('on');
setTimeout(() => this.bar.classList.toggle('on'), 2000);
}
}

View File

@@ -0,0 +1,350 @@
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/proxy.json
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/schemas/proxy.json
// https://bugzilla.mozilla.org/show_bug.cgi?id=1804693
// Setting single proxy for all fails
// https://bugs.chromium.org/p/chromium/issues/detail?id=1495756
// Issue 1495756: Support bypassList for PAC scripts in the chrome.proxy API
// https://chromium-review.googlesource.com/c/chromium/src/+/5227338
// Implement bypassList for PAC scripts in chrome.proxy API
// Chrome bypassList applies to 'fixed_servers', not 'pac_script' or URL
// Firefox passthrough applies to all set in proxy.settings.set, i.e. PAC URL
// manual bypass list:
// Chrome: pac_script data, not possible for URL
// Firefox proxy.onRequest
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/parent/ext-proxy.js#236
// throw new ExtensionError("proxy.settings is not supported on android.");
// https://bugzilla.mozilla.org/show_bug.cgi?id=1725981
// Support proxy.settings API on Android
import {App} from './app.js';
import {Authentication} from './authentication.js';
import {OnRequest} from './on-request.js';
import {Location} from './location.js';
import {Pattern} from './pattern.js';
import {Action} from './action.js';
import {Menus} from './menus.js';
export class Proxy {
static {
// from popup.js & options.js
browser.runtime.onMessage.addListener((...e) => this.onMessage(...e));
}
static onMessage(message) {
// noDataChange comes from popup.js & test.js
const {id, pref, host, proxy, dark, tab, noDataChange} = message;
switch (id) {
case 'setProxy':
Action.dark = dark;
this.set(pref, noDataChange);
break;
case 'includeHost':
case 'excludeHost':
// proxy object reference to pref is lost in chrome when sent from popup.js
const pxy = pref.data.find(i => i.active && host === `${i.hostname}:${i.port}`);
this.includeHost(pref, pxy, tab, id);
break;
case 'setTabProxy':
OnRequest.setTabProxy(tab, proxy);
break;
case 'getTabProxy':
// need to return a promise for 'getTabProxy' from popup.js
return Promise.resolve(OnRequest.tabProxy[tab.id]);
case 'getIP':
this.getIP();
break;
}
}
static async set(pref, noDataChange) {
// check if proxy.settings is controlled_by_this_extension
const conf = await this.getSettings();
// not controlled_by_this_extension
if (!conf) { return; }
// --- update authentication data
noDataChange || Authentication.init(pref.data);
// --- update menus
noDataChange || Menus.init(pref);
// --- check mode
switch (true) {
// no proxy, set to disable
case !pref.data[0]:
pref.mode = 'disable';
break;
// no include pattern, set proxy to the first entry
case pref.mode === 'pattern' && !pref.data.some(i => i.include[0] || i.exclude[0]):
const pxy = pref.data[0];
pref.mode = pxy.type === 'pac' ? pxy.pac : `${pxy.hostname}:${pxy.port}`;
break;
}
App.firefox ? Firefox.set(pref, conf) : Chrome.set(pref);
Action.set(pref);
}
static async getSettings() {
if (App.android) { return {}; }
const conf = await browser.proxy.settings.get({});
// https://developer.chrome.com/docs/extensions/mv3/manifest/icons/
// https://bugs.chromium.org/p/chromium/issues/detail?id=29683
// Issue 29683: Extension icons should support SVG (Dec 8, 2009)
// SVG is not supported by Chrome
// Firefox: If each one of imageData and path is one of undefined, null or empty object,
// the global icon will be reset to the manifest icon
// Chrome -> Error: Either the path or imageData property must be specified.
// check if proxy.settings is controlled_by_this_extension
const control = ['controlled_by_this_extension', 'controllable_by_this_extension'].includes(conf.levelOfControl);
const path = control ? `/image/icon.png` : `/image/icon-off.png`;
browser.action.setIcon({path});
!control && browser.action.setTitle({title: browser.i18n.getMessage('controlledByOtherExtensions')});
// return null if Chrome and no control, allow Firefox to continue regardless
return !App.firefox && !control ? null : conf;
}
// ---------- Include/Exclude Host ----------------------
static includeHost(pref, proxy, tab, inc) {
// not for storage.managed
if (pref.managed) { return; }
const url = this.getURL(tab.url);
if (!url) { return; }
const pattern = url.origin + '/';
const pat = {
active: true,
pattern,
title: url.hostname,
type: 'wildcard',
};
inc === 'includeHost' ? proxy.include.push(pat) : proxy.exclude.push(pat);
browser.storage.local.set({data: pref.data});
// update Proxy, noDataChange
pref.mode === 'pattern' && proxy.active && this.set(pref, true);
}
static getURL(str) {
const url = new URL(str);
// unacceptable URLs
if (!['http:', 'https:'].includes(url.protocol)) { return; }
return url;
}
// from popup.js
static getIP() {
fetch('https://getfoxyproxy.org/webservices/lookup.php')
.then(response => response.json())
.then(data => {
if (!Object.keys(data)) {
App.notify(browser.i18n.getMessage('error'));
return;
}
const [ip, {cc, city}] = Object.entries(data)[0];
const text = [ip, city, Location.get(cc)].filter(Boolean).join('\n');
App.notify(text);
})
.catch(error => App.notify(browser.i18n.getMessage('error') + '\n\n' + error.message));
}
}
class Firefox {
static async set(pref, conf) {
// update OnRequest
OnRequest.init(pref);
if (App.android) { return; }
// incognito access
if (!await browser.extension.isAllowedIncognitoAccess()) {
return;
}
// retain settings as Network setting is partially customisable
const value = conf.value;
switch (true) {
// https://github.com/foxyproxy/browser-extension/issues/47
// Unix domain socket SOCKS proxy support
// regard file:///run/user/1000/proxy.socks:9999 as normal proxy (not PAC)
// sanitizeNoProxiesPref() "network.proxy.no_proxies_on"
// https://searchfox.org/mozilla-central/source/browser/components/preferences/dialogs/connection.js#338
// --- Proxy Auto-Configuration (PAC) URL
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
value.proxyType = 'autoConfig';
value.autoConfigUrl = pref.mode;
// convert to standard comma-separated
value.passthrough = pref.passthrough.split(/[\s,;]+/).join(', ');
value.proxyDNS = pref.proxyDNS;
// no error if levelOfControl: "controlled_by_other_extensions"
browser.proxy.settings.set({value});
break;
// --- disable, direct, pattern, or single proxy
default:
browser.proxy.settings.clear({});
}
}
}
class Chrome {
static async set(pref) {
// https://developer.chrome.com/docs/extensions/reference/types/
// Scope and life cycle: regular | regular_only | incognito_persistent | incognito_session_only
const config = {value: {}, scope: 'regular'};
switch (true) {
case pref.mode === 'disable':
case pref.mode === 'direct':
config.value.mode = 'system';
break;
// --- Proxy Auto-Configuration (PAC) URL
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
config.value.mode = 'pac_script';
config.value.pacScript = {mandatory: true};
config.value.pacScript.url = pref.mode;
break;
// --- single proxy
case pref.mode.includes(':'):
const pxy = this.findProxy(pref);
if (!pxy) { return; }
config.value.mode = 'fixed_servers';
config.value.rules = this.getSingleProxyRule(pref, pxy);
break;
// --- pattern
default:
config.value.mode = 'pac_script';
config.value.pacScript = {mandatory: true};
config.value.pacScript.data = this.getPacString(pref);
}
browser.proxy.settings.set(config);
// --- incognito
this.setIncognito(pref);
}
static findProxy(pref, host = pref.mode) {
return pref.data.find(i =>
i.active && i.type !== 'pac' && i.hostname && host === `${i.hostname}:${i.port}`);
}
static async setIncognito(pref) {
// incognito access
if (!await browser.extension.isAllowedIncognitoAccess()) {
return;
}
const pxy = pref.container?.incognito && this.findProxy(pref, pref.container?.incognito);
if (!pxy) {
// unset incognito
browser.proxy.settings.clear({scope: 'incognito_persistent'});
return;
}
const config = {value: {}, scope: 'incognito_persistent'};
config.value.mode = 'fixed_servers';
config.value.rules = this.getSingleProxyRule(pref, pxy);
browser.proxy.settings.set(config);
}
static getSingleProxyRule(pref, pxy) {
return {
singleProxy: {
scheme: pxy.type,
host: pxy.hostname,
// must be number, prepare for augmented port
port: parseInt(pxy.port),
},
bypassList: pref.passthrough ? pref.passthrough.split(/[\s,;]+/) : []
};
}
static getProxyString(proxy) {
let {type, hostname, port} = proxy;
switch (type) {
case 'direct':
return 'DIRECT';
// chrome PAC doesn't support HTTP
case 'http':
type = 'PROXY';
break;
default:
type = type.toUpperCase();
}
// prepare for augmented port
return `${type} ${hostname}:${parseInt(port)}`;
}
static getPacString(pref) {
// --- proxy by pattern
const [passthrough, net] = Pattern.getPassthrough(pref.passthrough);
// filter data
let data = pref.data.filter(i => i.active && i.type !== 'pac' && i.hostname);
data = data.filter(i => i.include[0] || i.exclude[0]).map(item => {
return {
str: this.getProxyString(item),
include: item.include.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
exclude: item.exclude.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type))
};
});
// add PAC rules from pacString
let pacData = pref.data.filter(i => i.active && i.type === 'pac' && i.pacString);
pacData = pacData.map((i, idx) => i.pacString.replace('FindProxyForURL', '$&' + idx) +
`\nconst find${idx} = FindProxyForURL${idx}(url, host);
if (find${idx} !== 'DIRECT') { return find${idx}; }`).join('\n\n');
pacData &&= `\n${pacData}\n`;
// https://developer.chrome.com/docs/extensions/reference/proxy/#type-PacScript
// https://github.com/w3c/webextensions/issues/339
// Chrome pacScript doesn't support bypassList
// https://issues.chromium.org/issues/40286640
// isInNet(host, "192.0.2.172", "255.255.255.255")
const pacString =
String.raw`function FindProxyForURL(url, host) {
const data = ${JSON.stringify(data)};
const passthrough = ${JSON.stringify(passthrough)};
const net = ${JSON.stringify(net)};
const match = array => array.some(i => new RegExp(i, 'i').test(url));
const inNet = () => net[0] && /^[\d.]+$/.test(host) && net.some(([ip, mask]) => isInNet(host, ip, mask));
if (match(passthrough) || inNet()) { return 'DIRECT'; }
for (const proxy of data) {
if (!match(proxy.exclude) && match(proxy.include)) { return proxy.str; }
}
${pacData}
return 'DIRECT';
}`;
return pacString;
}
}

View File

@@ -0,0 +1,66 @@
{
"type": "object",
"required": ["mode", "data"],
"properties": {
"mode": {"type": "string"},
"sync": {"type": "boolean"},
"autoBackup": {"type": "boolean"},
"passthrough": {"type": "string"},
"theme": {"type": "string"},
"data": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"active": {"type": "boolean"},
"title": {"type": "string"},
"type": {"type": "string", "enum": ["http", "https", "socks4", "socks5", "pac", "direct"]},
"hostname": {"type": "string", "format": "hostname"},
"port": {"type": "string", "pattern": "^\\d*$"},
"username": {"type": "string"},
"password": {"type": "string"},
"cc": {"type": "string", "pattern": "^([A-Z]{2}|)$"},
"city": {"type": "string"},
"color": {"type": "string"},
"pac": {"type": "string"},
"pacString": {"type": "string"},
"proxyDNS": {"type": "boolean"},
"include": {
"type": "array",
"id": "include",
"items": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["wildcard", "regex", "match"]},
"title": {"type": "string"},
"pattern": {"type": "string"},
"active": {"type": "boolean"}
},
"required": ["type", "title", "pattern", "active"]
}
},
"exclude": {"$ref": "include"},
"tabProxy": {"$ref": "include"}
}
}
},
"container": {
"type": "object",
"properties": {
"incognito": {"type": "string"},
"container-1": {"type": "string"},
"container-2": {"type": "string"}
}
},
"commands": {
"type": "object",
"properties": {
"setProxy": {"type": "string"},
"setTabProxy": {"type": "string"},
"includeHost": {"type": "string"},
"excludeHost": {"type": "string"}
}
}
}
}

View File

@@ -0,0 +1,17 @@
import {App} from './app.js';
// ---------- Show (Side Effect) ---------------------------
class Show {
static {
const {basic, firefox} = App;
basic && document.body.classList.add('basic');
!firefox && document.body.classList.add('chrome');
const elem = document.querySelector('img[src="../image/icon.svg"]');
elem?.addEventListener('click', () => {
basic && document.body.classList.toggle('basic');
!firefox && document.body.classList.toggle('chrome');
});
}
}

View File

@@ -0,0 +1,53 @@
/* ----- Spinner ----- */
div.spinner {
display: none;
grid-auto-flow: column;
align-items: center;
justify-content: center;
background-color: #0003;
margin: 0;
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1;
transition: all 0.5s ease-in-out;
}
.spinner::before {
content: '';
width: 8em;
height: 8em;
border: 1em solid #ddd;
border-color: #ddd var(--btn-bg) #ddd var(--btn-bg);
border-radius: 50%;
animation: spin 1.5s linear infinite;
margin-left: -2em;
}
.spinner::after {
content: '';
background-image: url('../image/icon.svg');
background-repeat: no-repeat;
background-size: contain;
width: 6em;
height: 6em;
margin-left: -8em;
opacity: 0.6;
}
.spinner.on {
display: grid;
animation: fade-in 0.5s ease-in-out;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}

View File

@@ -0,0 +1,13 @@
// ---------- Spinner --------------------------------------
export class Spinner {
static spinner = document.querySelector('.spinner');
static show() {
this.spinner.classList.add('on');
}
static hide() {
this.spinner.classList.remove('on');
}
}

View File

@@ -0,0 +1,93 @@
import {App} from './app.js';
// ---------- Storage Sync ---------------------------------
export class Sync {
static init(pref) {
// not for storage.managed
if (pref.managed) { return; }
// Firefox 101 (2022-05-31), Chrome 73
browser.storage.sync.onChanged.addListener(e => this.onChanged(e));
}
static async onChanged(changes) {
const pref = await browser.storage.local.get();
this.getSync(pref);
}
static async get(pref) {
// check storage.managed
await this.getManaged(pref);
// check storage.sync
await this.getSync(pref);
}
// https://bugzilla.mozilla.org/show_bug.cgi?id=1868153
// On Firefox storage.managed returns undefined if not found
static async getManaged(pref) {
const result = await browser.storage.managed.get().catch(() => {});
if (!Array.isArray(result?.data) || !result.data[0]) {
// storage.managed not found, clean up
if (Object.hasOwn(pref, 'managed')) {
delete pref.managed;
await browser.storage.local.remove('managed');
}
return;
}
// get default pref
const db = App.getDefaultPref();
// revert pref to default values
Object.keys(db).forEach(i => pref[i] = db[i]);
// set data from storage.managed
Object.keys(result).forEach(i => Object.hasOwn(pref, i) && (pref[i] = result[i]));
// set pref.managed to use in options.js, popup.js
pref.managed = true;
// no sync for storage.managed
pref.sync = false;
// --- update database
await browser.storage.local.set(pref);
}
static hasOldData(obj) {
// FP v3 OR FP v7, null value causes an error
return Object.hasOwn(obj, 'settings') || Object.values(obj).some(i => i && Object.hasOwn(i, 'address'));
}
static async getSync(pref) {
if (!pref.sync) { return; }
if (pref.managed) { return; }
const syncPref = await browser.storage.sync.get();
// check sync from old version 3-7
// (local has no data OR has old data) AND sync has old data
if ((!Object.keys(pref)[0] || this.hasOldData(pref)) && this.hasOldData(syncPref)) {
// set sync data to pref, to migrate next in background.js
Object.keys(syncPref).forEach(i => pref[i] = syncPref[i]);
return;
}
// convert object to array & filter proxies
const data = Object.values(syncPref).filter(i => Object.hasOwn(i, 'hostname'));
const obj = {};
if (data[0] && !App.equal(pref.data, data)) {
obj.data = data;
pref.data = data;
}
App.syncProperties.forEach(i => {
if (Object.hasOwn(syncPref, i)) {
obj[i] = syncPref[i];
pref[i] = syncPref[i];
}
});
// update saved pref
Object.keys(obj)[0] && await browser.storage.local.set(obj);
}
}

View File

@@ -0,0 +1,156 @@
import {App} from './app.js';
import {Popup} from './options-popup.js';
// ---------- Proxy Text (Side Effect) ---------------------------
class ProxyTest {
static {
document.querySelector('.proxy-top button[data-i18n="test"]').addEventListener('click', () => this.selectOptions());
this.popupProxy = document.querySelector('.popup select.popup-test-proxy');
this.popupServer = document.querySelector('.popup select.popup-server');
this.popupServer.addEventListener('change', () => this.process());
}
static selectOptions() {
if (this.popupProxy.options.length < 2) {
Popup.show('Did not find a suitable proxy');
Popup.show('Ending the test');
return;
}
this.popupProxy.classList.add('on');
this.popupServer.classList.add('on');
!App.firefox && Popup.show('On Chrome, proxy authentication must be done before starting the test');
Popup.show('Please select a proxy (or the first one will be selected) and then a server for the test\n');
}
static async process(e) {
this.server = this.popupServer.value;
if (!this.server) { return; }
Popup.show('Starting the proxy Test\n');
// check 'prefers-color-scheme' since it is not available in background service worker
this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// --- get the IP check server
const serverText = this.popupServer.selectedOptions[0].textContent;
Popup.show(`IP check server: ${serverText}`);
// --- get the proxy for the test
// selected proxy or the first proxy
!this.popupProxy.value && (this.popupProxy.selectedIndex = 1);
const id = this.popupProxy.value;
const host = this.popupProxy.selectedOptions[0].textContent;
const pref = await browser.storage.local.get();
const pxy = pref.data.find(i => id === `${i.hostname}:${i.port}`);
Popup.show(`Testing proxy: ${host}\n`);
// --- get real IP
Popup.show('Setting mode to "Disable" to get your real IP');
pref.mode = 'disable';
await this.setProxy(pref);
const realIP = await this.getIP();
// --- test Tab Proxy with mode disable
App.firefox && await this.tabProxy(pxy, realIP);
// --- test single proxy
Popup.show(`Setting mode to "${host}"`);
pref.mode = `${pxy.hostname}:${pxy.port}`;
await this.setProxy(pref);
await this.getIP();
// --- test Proxy by Patterns
Popup.show('Setting mode to "Proxy by Patterns"');
// adding patterns to test
pxy.include = [
{
type: 'wildcard',
title: 'test',
pattern: new URL(this.server).hostname,
active: true
},
];
pref.mode = 'pattern';
await this.setProxy(pref);
await this.getIP();
// --- reset to the original state
this.reset();
}
static async setProxy(pref) {
// await runtime.sendMessage resolves early on Chrome
App.firefox ? await this.sendMessage(pref) : await this.chromeSendMessage(pref);
}
static sendMessage(pref) {
return browser.runtime.sendMessage({id: 'setProxy', pref, dark: this.dark, noDataChange: true});
}
static async chromeSendMessage(pref) {
await new Promise(resolve => {
const listener = () => {
browser.proxy.settings.onChange.removeListener(listener);
resolve();
};
browser.proxy.settings.onChange.addListener(listener);
this.sendMessage(pref);
});
}
static async tabProxy(pxy, realIP) {
Popup.show('Setting Tab Proxy with mode "Disable"');
const tab = await browser.tabs.create({active: false});
await browser.runtime.sendMessage({id: 'setTabProxy', proxy: pxy, tab});
await new Promise(resolve => {
const listener = e => {
browser.tabs.remove(tab.id);
browser.webRequest.onBeforeRequest.removeListener(listener);
Popup.show(`Your IP: ${e.proxyInfo.host || realIP}\n`);
resolve();
};
browser.webRequest.onBeforeRequest.addListener(listener, {urls: ['<all_urls>'], tabId: tab.id});
browser.tabs.update(tab.id, {url: this.server});
});
}
static async reset() {
Popup.show('Resetting options to their original state');
const pref = await browser.storage.local.get();
await this.setProxy(pref);
Popup.show('Ending the proxy test\n');
// reset select elements
this.popupProxy.selectedIndex = 0;
this.popupServer.selectedIndex = 0;
}
static async getIP() {
// Chrome a network request timeouts at 300 seconds, while in Firefox at 90 seconds.
// AbortSignal.timeout FF100, Ch124
return fetch(this.server, {cache: 'no-store', signal: AbortSignal.timeout(5000)})
.then(r => r.ok ? r.text() : this.response(r))
.then(text => {
// HTML response is not acceptable
const ip = text.includes('<') ? 'undefined' : text.trim();
Popup.show(`Your IP: ${ip}\n`);
return ip;
})
.catch(e => Popup.show(`Your IP: undefined\nStatus: ${e.message}\n`));
}
static response(r) {
switch (r.status) {
case 403:
return 'undefined\nStatus: 403 Forbidden';
default:
return `undefined\nStatus: ${r.status} ${r.statusText}`;
}
}
}

View File

@@ -0,0 +1,68 @@
/* ----- Tester ----- */
.tester h3 {
margin: 0;
}
.tester h3:nth-of-type(2) {
margin-top: 1.5em;
}
.tester .tester-pattern {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5em;
}
.tester input {
padding: 0.5em;
}
.tester .buttons {
display: grid;
grid-auto-flow: column;
gap: 0.5em;
}
.tester button:first-child{
justify-self: start;
}
.tester button:last-child {
justify-self: end;
}
.tester pre {
color: inherit;
/* background-color: var(--alt-bg); */
border: 1px solid var(--border);
border-radius: 0.3em;
padding: 0.5em;
font-size: 1.1em;
min-height: 10em;
background: linear-gradient(to right, var(--hover) 0, var(--hover) 2em, transparent 2em, transparent 100%);
padding-left: 2.5em;
}
.tester pre span {
margin-left: -2em;
}
.pass {
color: var(--pass);
}
.fail {
color: var(--fail);
}
:is(.pass, .fail)::before {
content: '✔';
display: inline-block;
width: 1em;
margin-right: 1em;
text-align: center;
}
.fail::before {
content: '✘';
}

View File

@@ -0,0 +1,116 @@
import {Pattern} from './pattern.js';
import {Nav} from './nav.js';
export class Tester {
static {
this.select = document.querySelector('.tester select');
this.select.addEventListener('change', () => this.process());
[this.pattern, this.url] = document.querySelectorAll('.tester input');
[this.pre, this.pre2] = document.querySelectorAll('.tester pre');
// convert generated HTML to plaintext
this.pre.addEventListener('input', e => {
if ((e.data || e.inputType === 'insertFromPaste') && this.pre.matches('.tested')) {
[...this.pre.children].forEach(i => i.replaceWith(i.textContent));
this.pre.classList.remove('tested');
}
});
document.querySelector('.tester button[data-i18n="back"]').addEventListener('click', () => this.back());
document.querySelector('.tester button[data-i18n="test"]').addEventListener('click', () => this.process());
document.querySelector('.tester button.proxyByPatterns').addEventListener('click', () => this.processURL());
}
static process() {
this.pattern.value = this.pattern.value.trim();
const str = this.pre.textContent.trim();
if (!this.pattern.value || !str) {
return;
}
// validate pattern
const valid = Pattern.validate(this.pattern.value, this.select.value, true);
if (!valid) { return; }
// convert pattern to regex string if needed
const pat = Pattern.get(this.pattern.value, this.select.value);
const regex = new RegExp(pat, 'i');
const arr = [];
str.split(/\s/).forEach(i => {
const sp = document.createElement('span');
sp.textContent = i;
i.trim() && (sp.className = regex.test(i) ? 'pass' : 'fail');
arr.push(sp, '\n');
});
this.pre.textContent = '';
this.pre.append(...arr);
// mark pre, used for 'input' event
this.pre.classList.add('tested');
}
static back() {
if (!this.target) { return; }
// show Proxy tab
Nav.get('proxies');
// go to target proxy
const details = this.target.closest('details');
details.open = true;
this.target.scrollIntoView({behavior: 'smooth'});
this.target.focus();
// reset
this.target = null;
}
static async processURL() {
const url = this.url.value.trim();
if (!url || !this.url.checkValidity()) { return; }
let {data} = await browser.storage.local.get({data: []});
data = data.filter(i => i.active && !i.pac && (i.include[0] || i.exclude[0] || i.tabProxy?.[0])).map(item => {
item.tabProxy ||= [];
return {
type: item.type,
hostname: item.hostname,
port: item.port,
title: item.title,
include: item.include.filter(i => i.active),
exclude: item.exclude.filter(i => i.active),
tabProxy: item.tabProxy.filter(i => i.active),
};
});
const match = arr => arr.find(i => new RegExp(Pattern.get(i.pattern, i.type), 'i').test(url));
let arr = [];
data.forEach(i => {
const target = i.title || `${i.hostname}:${parseInt(i.port)}`;
if (!match(i.exclude)) {
const p = match(i.include);
p && arr.push([target, p.type, p.pattern]);
}
const p = match(i.tabProxy);
p && arr.push([target, p.type, p.pattern, '(Tab Proxy)']);
});
if (!arr[0]) {
this.pre2.textContent = '⮕ DIRECT';
return;
}
// --- text formatting
const n = 4;
const w = 'wildcard'.length + n;
const p0 = Math.max(...arr.map(i => i[0].length)) + n;
const p2 = Math.max(...arr.map(i => i[2].length)) + n;
// undefined or null is converted to an empty string in join()
arr = arr.map(i => [i[0].padEnd(p0, ' '), i[1].padEnd(w, ' '), i[2].padEnd(p2, ' '), i[3]].join('').trim());
this.pre2.textContent = arr.join('\n');
}
}

View File

@@ -0,0 +1,55 @@
/* ---------- Alternative Themes ---------- */
/* ----- moonlight ----- */
:root.moonlight {
--bg: #fff;
--body-bg: #f9f9fb;
--nav-bg: #d9d9db;
--nav-hover: var(--hover);
--nav-color: var(--color);
}
:root.moonlight .flat {
color: inherit;
background-color: var(--bg);
border: 1px solid var(--border);
}
:root.moonlight .flat:hover {
background-color: var(--hover);
}
:root.moonlight .pattern-head button span.plus {
filter: unset;
}
:root.moonlight .popup-buttons button {
color: inherit;
background-color: var(--alt-bg);
}
:root.moonlight .popup-buttons button:hover {
background-color: var(--hover);
}
@media screen and (prefers-color-scheme: dark) {
:root.moonlight {
--bg: #333;
--body-bg: #444;
--nav-bg: #000;
}
:root.moonlight .pattern-head button span.plus {
filter: brightness(0) invert(1);
}
}
/* ----- /moonlight ----- */
/* ----- alt ----- */
:root.alt {
--body-bg: var(--bg);
}
/* ----- /alt ----- */

View File

@@ -0,0 +1,18 @@
// ---------- Theme (Side Effect) --------------------------
class Theme {
static {
this.elem = [document, ...[...document.querySelectorAll('iframe')].map(i => i.contentDocument)];
document.getElementById('theme').addEventListener('change', e => this.set(e.target.value));
browser.storage.local.get('theme').then(i => {
i.theme && this.set(i.theme);
// show after
document.body.style.opacity = 1;
});
}
static set(value) {
this.elem.forEach(i => i.documentElement.className = value);
}
}

View File

@@ -0,0 +1,43 @@
/* ----- Toggle Switch ----- */
/* round() FF118, Ch125 */
.toggle {
--toggle-dot: 12px;
--toggle-dot-margin: 1px;
--toggle-wh-ratio: 2.4;
--toggle-w: calc(var(--toggle-dot) * var(--toggle-wh-ratio) + var(--toggle-dot-margin) * 2);
--toggle-h: calc(var(--toggle-dot) + var(--toggle-dot-margin) * 2);
--toggle-trans: calc(var(--toggle-w) - var(--toggle-h));
appearance: none;
width: var(--toggle-w);
height: var(--toggle-h);
position: relative;
border-radius: 50px;
background-color: #ccc;
transition: background-color 0.5s;
}
.toggle::before {
content: '';
display: block;
margin: var(--toggle-dot-margin);
width: var(--toggle-dot);
height: var(--toggle-dot);
background-color: #fff;
border-radius: 50%;
color: #fff;
transition: 0.5s;
}
.toggle:checked {
background-color: var(--btn-bg);
}
.toggle:checked::before {
transform: translateX(var(--toggle-trans));
}
/* smaller toggle switch for patterns */
.pattern-row .toggle {
--toggle-dot: 10px;
}

View File

@@ -0,0 +1,10 @@
// ---------- Toggle ---------------------------------------
export class Toggle {
static password(elem) {
elem.addEventListener('click', () => {
const input = elem.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
}
}

View File

@@ -0,0 +1,39 @@
import {App} from './app.js';
// ---------- WebRTC (Side Effect) -------------------------
class WebRTC {
static {
this.webRTC = document.querySelector('#limitWebRTC');
// firefox only option
!App.firefox && (this.webRTC.lastElementChild.disabled = true);
this.webRTC.addEventListener('change', () => this.process());
this.init();
}
static async init() {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/request
// Any permissions granted are retained by the extension, even over upgrade and disable/enable cycling.
// check if permission is granted
this.permission = await browser.permissions.contains({permissions: ['privacy']});
// check webRTCIPHandlingPolicy
if (this.permission) {
const result = await browser.privacy.network.webRTCIPHandlingPolicy.get({});
this.webRTC.value = result.value;
}
}
static async process() {
if (!this.permission) {
// request permission, Firefox for Android version 102
this.permission = await browser.permissions.request({permissions: ['privacy']});
if (!this.permission) { return; }
}
// https://bugzilla.mozilla.org/show_bug.cgi?id=1790270
// WebRTC bypasses Network settings & proxy.onRequest
// {"levelOfControl": "controllable_by_this_extension", "value": "default"}
browser.privacy.network.webRTCIPHandlingPolicy.set({value: this.webRTC.value});
}
}