This commit is contained in:
wildtail
2026-05-14 02:59:58 +03:00
parent 40538a2c18
commit 52197d4cae
7 changed files with 5172 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+4172
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "proxy-container",
"version": "1.0.0",
"description": "Firefox extension settings page only",
"scripts": {
"dev": "web-ext run --source-dir=src",
"build": "web-ext build --source-dir=src --artifacts-dir=dist"
},
"devDependencies": {
"web-ext": "^8.0.0"
}
}
+275
View File
@@ -0,0 +1,275 @@
const STORAGE_KEY = "containerSettings";
const IGNORED_URL_PREFIXES = [
"about:",
"moz-extension:",
"chrome:",
"file:",
"view-source:",
"data:",
"blob:",
"javascript:"
];
const redirectedTabIds = new Set();
browser.action.onClicked.addListener(() => {
browser.runtime.openOptionsPage();
});
async function getContainerSettingsMap() {
const result = await browser.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] || {};
}
function normalizeProxyType(type) {
if (type === "socks5") {
return "socks";
}
const allowedTypes = ["http", "https", "socks", "socks4"];
if (allowedTypes.includes(type)) {
return type;
}
return "http";
}
function shouldIgnoreUrl(url) {
if (!url) {
return true;
}
return IGNORED_URL_PREFIXES.some((prefix) => url.startsWith(prefix));
}
function escapeRegex(value) {
return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
}
function globToRegex(glob) {
let pattern = String(glob || "").trim();
if (!pattern) {
return null;
}
if (!pattern.includes("://")) {
pattern = `*://${pattern}`;
}
pattern = pattern.replace(
/^(\*:\/\/|https?:\/\/)\*\./,
"$1{SUBDOMAIN_OR_ROOT}"
);
let regex = escapeRegex(pattern);
regex = regex
.replaceAll("\\*", ".*")
.replaceAll("\\?", ".");
regex = regex.replaceAll(
"\\{SUBDOMAIN_OR_ROOT\\}",
"([^/]+\\.)?"
);
return new RegExp(`^${regex}(/.*)?$`, "i");
}
function testUrlRule(rule, url) {
try {
const regex = globToRegex(rule);
return regex ? regex.test(url) : false;
} catch (error) {
console.warn("Invalid URL rule skipped:", rule, error);
return false;
}
}
async function findContainerForUrl(url) {
const settingsMap = await getContainerSettingsMap();
for (const [cookieStoreId, settings] of Object.entries(settingsMap)) {
const urlRules = settings.urlRules || [];
console.log(urlRules)
for (const rule of urlRules) {
if (testUrlRule(rule, url)) {
return cookieStoreId;
}
}
}
return null;
}
async function redirectTabToContainer(tabId, tab, targetCookieStoreId) {
if (!targetCookieStoreId) {
return;
}
if (!tab || !tab.url) {
return;
}
if (tab.cookieStoreId === targetCookieStoreId) {
return;
}
if (redirectedTabIds.has(tabId)) {
return;
}
redirectedTabIds.add(tabId);
try {
await browser.tabs.create({
url: tab.url,
cookieStoreId: targetCookieStoreId,
active: tab.active,
index: tab.index
});
await browser.tabs.remove(tabId);
} catch (error) {
console.error("Failed to redirect tab to container:", error);
} finally {
setTimeout(() => {
redirectedTabIds.delete(tabId);
}, 1500);
}
}
/**
* URL routing:
* пользователь открывает URL -> правило находит контейнер -> вкладка переоткрывается в контейнере.
*/
browser.tabs.onUpdated.addListener(
async (tabId, changeInfo, tab) => {
if (!changeInfo.url) {
return;
}
const url = changeInfo.url;
if (shouldIgnoreUrl(url)) {
return;
}
try {
const targetCookieStoreId = await findContainerForUrl(url);
console.log("targetCookieStoreId", url)
if (!targetCookieStoreId) {
return;
}
await redirectTabToContainer(tabId, tab, targetCookieStoreId);
} catch (error) {
console.error("URL routing failed:", error);
}
},
{
properties: ["url"]
}
);
/**
* Per-container proxy:
* запрос из контейнера -> cookieStoreId -> proxy-настройка контейнера.
*/
browser.proxy.onRequest.addListener(
async (requestDetails) => {
console.log("proxy")
const cookieStoreId = requestDetails.cookieStoreId;
if (!cookieStoreId || cookieStoreId === "firefox-default") {
return { type: "direct" };
}
const settingsMap = await getContainerSettingsMap();
const containerSettings = settingsMap[cookieStoreId];
if (!containerSettings || !containerSettings.proxy) {
return { type: "direct" };
}
const proxy = containerSettings.proxy;
if (!proxy.enabled) {
return { type: "direct" };
}
if (!proxy.host || !proxy.port) {
return { type: "direct" };
}
const proxyInfo = {
type: normalizeProxyType(proxy.type),
host: proxy.host,
port: Number(proxy.port)
};
if (
proxy.proxyDNS === true &&
(proxyInfo.type === "socks" || proxyInfo.type === "socks4")
) {
proxyInfo.proxyDNS = true;
}
return proxyInfo;
},
{
urls: ["<all_urls>"]
}
);
browser.proxy.onError.addListener((error) => {
console.error("Proxy error:", error);
});
/**
* Proxy authentication.
*/
browser.webRequest.onAuthRequired.addListener(
async (details) => {
if (!details.isProxy) {
return {};
}
const cookieStoreId = details.cookieStoreId;
if (!cookieStoreId) {
return {};
}
const settingsMap = await getContainerSettingsMap();
const containerSettings = settingsMap[cookieStoreId];
if (!containerSettings || !containerSettings.proxy) {
return {};
}
const proxy = containerSettings.proxy;
if (!proxy.enabled || !proxy.username) {
return {};
}
return {
authCredentials: {
username: proxy.username,
password: proxy.password || ""
}
};
},
{
urls: ["<all_urls>"]
},
["blocking"]
);
+42
View File
@@ -0,0 +1,42 @@
{
"manifest_version": 3,
"name": "Proxy Container",
"version": "1.0.0",
"description": "Firefox containers with regex routing and per-container proxy",
"permissions": [
"storage",
"contextualIdentities",
"proxy",
"tabs",
"cookies",
"webRequest",
"webRequestBlocking"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"scripts": [
"background.js"
]
},
"action": {
"default_title": "Открыть настройки Proxy Container"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"browser_specific_settings": {
"gecko": {
"id": "proxy-container@example.local",
"strict_min_version": "91.1.0"
}
}
}
+251
View File
@@ -0,0 +1,251 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Proxy Container Settings</title>
<style>
body {
max-width: 1100px;
margin: 40px auto;
padding: 0 20px;
font-family: Arial, sans-serif;
line-height: 1.5;
color: #222;
}
h1,
h2,
h3 {
margin-bottom: 14px;
}
.panel {
border: 1px solid #ccc;
padding: 16px;
margin-bottom: 24px;
border-radius: 6px;
background: #fff;
}
.container-card {
border: 1px solid #ddd;
padding: 16px;
margin-bottom: 14px;
border-radius: 6px;
background: #fafafa;
}
.field {
margin-bottom: 14px;
}
label {
display: block;
font-weight: 700;
margin-bottom: 5px;
}
input,
select,
textarea {
width: 100%;
box-sizing: border-box;
padding: 8px;
font-size: 14px;
}
input[type="checkbox"] {
width: auto;
}
textarea {
min-height: 96px;
font-family: Consolas, monospace;
}
button {
padding: 8px 12px;
cursor: pointer;
margin-right: 6px;
margin-top: 6px;
}
.container-title {
font-weight: 700;
font-size: 18px;
margin-bottom: 6px;
}
.small {
font-size: 12px;
color: #555;
word-break: break-all;
margin-bottom: 14px;
}
.danger {
color: #a40000;
}
.success {
color: #006800;
}
#status {
font-weight: 700;
margin-bottom: 18px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 760px) {
.grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
}
.muted {
color: #666;
font-size: 13px;
}
code {
background: #eee;
padding: 2px 4px;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>Proxy Container</h1>
<p id="status"></p>
<section class="panel">
<h2>Создать контейнер</h2>
<div class="grid">
<div class="field">
<label for="name">Название контейнера</label>
<input id="name" type="text" placeholder="Work" />
</div>
<div class="field">
<label for="color">Цвет</label>
<select id="color">
<option value="blue">blue</option>
<option value="turquoise">turquoise</option>
<option value="green">green</option>
<option value="yellow">yellow</option>
<option value="orange">orange</option>
<option value="red">red</option>
<option value="pink">pink</option>
<option value="purple">purple</option>
<option value="toolbar">toolbar</option>
</select>
</div>
</div>
<div class="field">
<label for="icon">Иконка</label>
<select id="icon">
<option value="fingerprint">fingerprint</option>
<option value="briefcase">briefcase</option>
<option value="cart">cart</option>
<option value="circle">circle</option>
<option value="dollar">dollar</option>
<option value="fence">fence</option>
<option value="gift">gift</option>
<option value="vacation">vacation</option>
<option value="food">food</option>
<option value="fruit">fruit</option>
<option value="pet">pet</option>
<option value="tree">tree</option>
<option value="chill">chill</option>
</select>
</div>
<div class="field">
<label for="urlRules">URL-правила, по одному на строку</label>
<textarea id="urlRules" placeholder="*.ru&#10;github.com/*&#10;https://*.example.com/*"></textarea>
<div class="muted">
Примеры: <code>*.ru</code>, <code>github.com/*</code>, <code>https://*.example.com/*</code>
</div>
</div>
<h3>Proxy</h3>
<div class="field">
<label class="checkbox-label">
<input id="proxyEnabled" type="checkbox" />
Использовать proxy для этого контейнера
</label>
</div>
<div class="grid">
<div class="field">
<label for="proxyType">Proxy type</label>
<select id="proxyType">
<option value="http">http</option>
<option value="https">https</option>
<option value="socks">socks</option>
<option value="socks4">socks4</option>
</select>
</div>
<div class="field">
<label for="proxyPort">Proxy port</label>
<input id="proxyPort" type="number" placeholder="8080" />
</div>
</div>
<div class="field">
<label for="proxyHost">Proxy host</label>
<input id="proxyHost" type="text" placeholder="127.0.0.1" />
</div>
<div class="grid">
<div class="field">
<label for="proxyUsername">Proxy username</label>
<input id="proxyUsername" type="text" placeholder="необязательно" />
</div>
<div class="field">
<label for="proxyPassword">Proxy password</label>
<input id="proxyPassword" type="password" placeholder="необязательно" />
</div>
</div>
<div class="field">
<label class="checkbox-label">
<input id="proxyDNS" type="checkbox" />
Proxy DNS через SOCKS
</label>
<div class="muted">
Имеет смысл только для SOCKS/SOCKS4.
</div>
</div>
<button id="createContainer">Создать контейнер</button>
</section>
<section>
<h2>Существующие контейнеры</h2>
<div id="containers"></div>
</section>
<script src="options.js"></script>
</body>
</html>
+418
View File
@@ -0,0 +1,418 @@
const STORAGE_KEY = "containerSettings";
const CONTAINER_COLORS = [
"blue",
"turquoise",
"green",
"yellow",
"orange",
"red",
"pink",
"purple",
"toolbar"
];
const CONTAINER_ICONS = [
"fingerprint",
"briefcase",
"cart",
"circle",
"dollar",
"fence",
"gift",
"vacation",
"food",
"fruit",
"pet",
"tree",
"chill"
];
const PROXY_TYPES = [
"http",
"https",
"socks",
"socks4"
];
const DEFAULT_PROXY = {
enabled: false,
type: "http",
host: "",
port: 8080,
username: "",
password: "",
proxyDNS: false
};
const nameInput = document.getElementById("name");
const colorInput = document.getElementById("color");
const iconInput = document.getElementById("icon");
const urlRulesInput = document.getElementById("urlRules");
const proxyEnabledInput = document.getElementById("proxyEnabled");
const proxyTypeInput = document.getElementById("proxyType");
const proxyHostInput = document.getElementById("proxyHost");
const proxyPortInput = document.getElementById("proxyPort");
const proxyUsernameInput = document.getElementById("proxyUsername");
const proxyPasswordInput = document.getElementById("proxyPassword");
const proxyDNSInput = document.getElementById("proxyDNS");
const createButton = document.getElementById("createContainer");
const containersElement = document.getElementById("containers");
const statusElement = document.getElementById("status");
function setStatus(message, isError = false) {
statusElement.textContent = message;
statusElement.className = isError ? "danger" : "success";
if (message) {
setTimeout(() => {
statusElement.textContent = "";
statusElement.className = "";
}, 3000);
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function parseUrlRules(text) {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function validateUrlRules(rules) {
for (const rule of rules) {
if (rule.includes(" ")) {
throw new Error(`URL-правило содержит пробел: ${rule}`);
}
if (rule.length > 500) {
throw new Error(`URL-правило слишком длинное: ${rule}`);
}
}
}
function validateProxy(proxy) {
if (!proxy.enabled) {
return;
}
if (!PROXY_TYPES.includes(proxy.type)) {
throw new Error("Некорректный proxy type");
}
if (!proxy.host) {
throw new Error("Proxy host пустой");
}
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
throw new Error("Proxy port должен быть от 1 до 65535");
}
}
async function getSettingsMap() {
const result = await browser.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] || {};
}
async function saveSettingsMap(settingsMap) {
await browser.storage.local.set({
[STORAGE_KEY]: settingsMap
});
}
function readCreateFormProxy() {
return {
enabled: proxyEnabledInput.checked,
type: proxyTypeInput.value,
host: proxyHostInput.value.trim(),
port: Number(proxyPortInput.value),
username: proxyUsernameInput.value.trim(),
password: proxyPasswordInput.value,
proxyDNS: proxyDNSInput.checked
};
}
function clearCreateForm() {
nameInput.value = "";
colorInput.value = "blue";
iconInput.value = "fingerprint";
urlRulesInput.value = "";
proxyEnabledInput.checked = false;
proxyTypeInput.value = "http";
proxyHostInput.value = "";
proxyPortInput.value = "";
proxyUsernameInput.value = "";
proxyPasswordInput.value = "";
proxyDNSInput.checked = false;
}
async function createContainer() {
const name = nameInput.value.trim();
const color = colorInput.value;
const icon = iconInput.value;
const urlRules = parseUrlRules(urlRulesInput.value);
const proxy = readCreateFormProxy();
if (!name) {
setStatus("Ошибка: название контейнера пустое", true);
return;
}
try {
validateUrlRules(urlRules);
validateProxy(proxy);
const container = await browser.contextualIdentities.create({
name,
color,
icon
});
const settingsMap = await getSettingsMap();
settingsMap[container.cookieStoreId] = {
urlRules,
proxy
};
await saveSettingsMap(settingsMap);
clearCreateForm();
setStatus("Контейнер создан");
await renderContainers();
} catch (error) {
setStatus(error.message, true);
}
}
function createOptions(values, selectedValue) {
return values
.map((value) => {
const selected = value === selectedValue ? "selected" : "";
return `<option value="${escapeHtml(value)}" ${selected}>${escapeHtml(value)}</option>`;
})
.join("");
}
function readCardProxy(card) {
return {
enabled: card.querySelector(".edit-proxy-enabled").checked,
type: card.querySelector(".edit-proxy-type").value,
host: card.querySelector(".edit-proxy-host").value.trim(),
port: Number(card.querySelector(".edit-proxy-port").value),
username: card.querySelector(".edit-proxy-username").value.trim(),
password: card.querySelector(".edit-proxy-password").value,
proxyDNS: card.querySelector(".edit-proxy-dns").checked
};
}
async function updateContainer(cookieStoreId) {
const card = document.querySelector(`[data-cookie-store-id="${cookieStoreId}"]`);
if (!card) {
setStatus("Ошибка: карточка контейнера не найдена", true);
return;
}
const name = card.querySelector(".edit-name").value.trim();
const color = card.querySelector(".edit-color").value;
const icon = card.querySelector(".edit-icon").value;
const urlRules = parseUrlRules(card.querySelector(".edit-url-rules").value);
const proxy = readCardProxy(card);
if (!name) {
setStatus("Ошибка: название контейнера пустое", true);
return;
}
try {
validateUrlRules(urlRules);
validateProxy(proxy);
await browser.contextualIdentities.update(cookieStoreId, {
name,
color,
icon
});
const settingsMap = await getSettingsMap();
settingsMap[cookieStoreId] = {
urlRules,
proxy
};
await saveSettingsMap(settingsMap);
setStatus("Контейнер обновлён");
await renderContainers();
} catch (error) {
setStatus(error.message, true);
}
}
async function removeContainer(cookieStoreId) {
const confirmed = confirm("Удалить контейнер? Это удалит Firefox-контейнер и его настройки.");
if (!confirmed) {
return;
}
try {
await browser.contextualIdentities.remove(cookieStoreId);
const settingsMap = await getSettingsMap();
delete settingsMap[cookieStoreId];
await saveSettingsMap(settingsMap);
setStatus("Контейнер удалён");
await renderContainers();
} catch (error) {
setStatus(error.message, true);
}
}
function renderContainerCard(container, storedSettings) {
const urlRules = storedSettings.urlRules || [];
const proxy = {
...DEFAULT_PROXY,
...(storedSettings.proxy || {})
};
const card = document.createElement("div");
card.className = "container-card";
card.dataset.cookieStoreId = container.cookieStoreId;
card.innerHTML = `
<div class="container-title">${escapeHtml(container.name)}</div>
<div class="small">cookieStoreId: ${escapeHtml(container.cookieStoreId)}</div>
<div class="grid">
<div class="field">
<label>Название</label>
<input class="edit-name" type="text" value="${escapeHtml(container.name)}" />
</div>
<div class="field">
<label>Цвет</label>
<select class="edit-color">
${createOptions(CONTAINER_COLORS, container.color)}
</select>
</div>
</div>
<div class="field">
<label>Иконка</label>
<select class="edit-icon">
${createOptions(CONTAINER_ICONS, container.icon)}
</select>
</div>
<div class="field">
<label>URL-правила</label>
<textarea class="edit-url-rules">${escapeHtml(urlRules.join("\n"))}</textarea>
<div class="muted">
Примеры: <code>*.ru</code>, <code>github.com/*</code>, <code>https://*.example.com/*</code>
</div>
</div>
<h3>Proxy</h3>
<div class="field">
<label class="checkbox-label">
<input class="edit-proxy-enabled" type="checkbox" ${proxy.enabled ? "checked" : ""} />
Использовать proxy для этого контейнера
</label>
</div>
<div class="grid">
<div class="field">
<label>Proxy type</label>
<select class="edit-proxy-type">
${createOptions(PROXY_TYPES, proxy.type)}
</select>
</div>
<div class="field">
<label>Proxy port</label>
<input class="edit-proxy-port" type="number" value="${escapeHtml(proxy.port)}" />
</div>
</div>
<div class="field">
<label>Proxy host</label>
<input class="edit-proxy-host" type="text" value="${escapeHtml(proxy.host)}" placeholder="127.0.0.1" />
</div>
<div class="grid">
<div class="field">
<label>Proxy username</label>
<input class="edit-proxy-username" type="text" value="${escapeHtml(proxy.username)}" placeholder="необязательно" />
</div>
<div class="field">
<label>Proxy password</label>
<input class="edit-proxy-password" type="password" value="${escapeHtml(proxy.password)}" placeholder="необязательно" />
</div>
</div>
<div class="field">
<label class="checkbox-label">
<input class="edit-proxy-dns" type="checkbox" ${proxy.proxyDNS ? "checked" : ""} />
Proxy DNS через SOCKS
</label>
</div>
<button class="save-container">Сохранить</button>
<button class="delete-container">Удалить</button>
`;
card.querySelector(".save-container").addEventListener("click", () => {
updateContainer(container.cookieStoreId);
});
card.querySelector(".delete-container").addEventListener("click", () => {
removeContainer(container.cookieStoreId);
});
return card;
}
async function renderContainers() {
containersElement.innerHTML = "";
try {
const containers = await browser.contextualIdentities.query({});
const settingsMap = await getSettingsMap();
if (!containers.length) {
containersElement.textContent = "Контейнеров пока нет.";
return;
}
for (const container of containers) {
const storedSettings = settingsMap[container.cookieStoreId] || {};
const card = renderContainerCard(container, storedSettings);
containersElement.appendChild(card);
}
} catch (error) {
setStatus(error.message, true);
}
}
createButton.addEventListener("click", createContainer);
renderContainers();