commit c61274e3a8b48af760aa1be30dfdfe77b6f80be8 Author: theshy Date: Tue Aug 12 22:49:47 2025 +0800 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..27f3d25 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Proxy Switcher(Chrome 扩展) + +一键切换 Chrome 代理:直连 / System / HTTP / SOCKS5 / PAC,支持弹窗选择与 PAC URL 保存。 + +## 使用方法 + +1. 打开 Chrome `chrome://extensions/` +2. 右上角打开“开发者模式” +3. 点击“加载已解压的扩展程序”,选择本目录 `2025-08-12_代理切换-浏览器扩展` +4. 点击工具栏图标,在弹窗中选择代理配置并点击“应用” +5. 选择 PAC 模式时,可在输入框中填写 PAC URL 后点击“保存” +6. 选择 自定义代理,可输入协议/地址/端口与绕过列表(逗号分隔),可保存或直接“应用” + +## 权限说明 + +- `proxy`: 设置浏览器代理 +- `storage`: 保存配置与当前选择 + +## 注意 + +- 如果系统或安全策略禁用了扩展修改代理,可能无法生效 +- PAC URL 需可访问且返回有效 PAC 脚本 +- 若切换到“系统代理/自动检测”仍无效,可尝试先切到“直连”,再切回“系统/自动”,或重载扩展。代码已在切换时先清理旧规则再设置以减少残留影响。 diff --git a/background.js b/background.js new file mode 100644 index 0000000..4f16e04 --- /dev/null +++ b/background.js @@ -0,0 +1,276 @@ +const STORAGE_KEYS = { + ACTIVE_PROFILE_ID: "activeProfileId", + PROFILES: "profiles", +}; + +const DEFAULT_PROFILES = [ + { id: "direct", name: "直连(关闭代理)", type: "direct" }, + { id: "system", name: "跟随系统代理", type: "system" }, + { id: "auto", name: "自动检测(WPAD)", type: "auto_detect" }, + { + id: "local-http", + name: "HTTP 127.0.0.1:7897", + type: "fixed", + scheme: "http", + host: "127.0.0.1", + port: 7897, + bypassList: ["localhost", "127.0.0.1"], + }, + { + id: "local-socks5", + name: "SOCKS5 127.0.0.1:7897", + type: "fixed", + scheme: "socks5", + host: "127.0.0.1", + port: 7897, + bypassList: ["localhost", "127.0.0.1"], + }, + { + id: "pac", + name: "PAC(自定义 URL)", + type: "pac", + pacUrl: "", + }, + { + id: "custom", + name: "自定义代理", + type: "fixed", + scheme: "http", + host: "", + port: 0, + bypassList: ["localhost", "127.0.0.1"], + }, +]; + +// 动态图标(仅使用 PNG) +// 只支持 PNG,去掉 SVG 支持与栅格化逻辑 +const ICON_FALLBACK_PNG = { + open: { 16: "icons/open-16.png", 32: "icons/open-32.png" }, + close: { 16: "icons/close-16.png", 32: "icons/close-32.png" }, +}; + +chrome.runtime.onInstalled.addListener(async () => { + const { profiles, activeProfileId } = await chrome.storage.local.get([ + STORAGE_KEYS.PROFILES, + STORAGE_KEYS.ACTIVE_PROFILE_ID, + ]); + const ensured = ensureProfiles(profiles); + await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured }); + if (!activeProfileId) { + await chrome.storage.local.set({ + [STORAGE_KEYS.ACTIVE_PROFILE_ID]: "direct", + }); + } + await applyActiveProfile(); +}); + +chrome.runtime.onStartup.addListener(async () => { + const { profiles } = await chrome.storage.local.get(STORAGE_KEYS.PROFILES); + const ensured = ensureProfiles(profiles); + await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured }); + await applyActiveProfile(); +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + (async () => { + if (message?.type === "applyProfileById") { + await chrome.storage.local.set({ + [STORAGE_KEYS.ACTIVE_PROFILE_ID]: message.profileId, + }); + await applyActiveProfile(); + sendResponse({ ok: true }); + } else if (message?.type === "updateCustomProxy") { + const { scheme, host, port, bypassList } = message; + const { profiles } = await chrome.storage.local.get( + STORAGE_KEYS.PROFILES + ); + const updated = (profiles || []).map((p) => + p.id === "custom" + ? { + ...p, + type: "fixed", + scheme: scheme || "http", + host: host || "", + port: Number(port) || 0, + bypassList: Array.isArray(bypassList) + ? bypassList + : typeof bypassList === "string" && bypassList.trim() + ? bypassList + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : p.bypassList || [], + } + : p + ); + await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: updated }); + const { activeProfileId } = await chrome.storage.local.get( + STORAGE_KEYS.ACTIVE_PROFILE_ID + ); + if (activeProfileId === "custom") { + await applyActiveProfile(); + } + sendResponse({ ok: true }); + } else if (message?.type === "updatePacUrl") { + const { profiles } = await chrome.storage.local.get( + STORAGE_KEYS.PROFILES + ); + const updated = (profiles || []).map((p) => + p.id === "pac" ? { ...p, pacUrl: message.pacUrl || "" } : p + ); + await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: updated }); + const { activeProfileId } = await chrome.storage.local.get( + STORAGE_KEYS.ACTIVE_PROFILE_ID + ); + if (activeProfileId === "pac") { + await applyActiveProfile(); + } + sendResponse({ ok: true }); + } else if (message?.type === "getState") { + const { profiles, activeProfileId } = await chrome.storage.local.get([ + STORAGE_KEYS.PROFILES, + STORAGE_KEYS.ACTIVE_PROFILE_ID, + ]); + const ensured = ensureProfiles(profiles); + if (ensured !== profiles) { + await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured }); + } + sendResponse({ ok: true, profiles: ensured, activeProfileId }); + } else if (message?.type === "resetProfiles") { + await chrome.storage.local.set({ + [STORAGE_KEYS.PROFILES]: DEFAULT_PROFILES, + [STORAGE_KEYS.ACTIVE_PROFILE_ID]: "direct", + }); + await applyActiveProfile(); + sendResponse({ ok: true }); + } + })(); + return true; +}); + +async function applyActiveProfile() { + const { profiles, activeProfileId } = await chrome.storage.local.get([ + STORAGE_KEYS.PROFILES, + STORAGE_KEYS.ACTIVE_PROFILE_ID, + ]); + const profile = + (profiles || DEFAULT_PROFILES).find((p) => p.id === activeProfileId) || + DEFAULT_PROFILES[0]; + const config = buildProxyConfig(profile); + // 为避免残留规则影响,先清理再设置 + await new Promise((resolve) => + chrome.proxy.settings.clear({ scope: "regular" }, resolve) + ); + await new Promise((resolve) => { + chrome.proxy.settings.set({ value: config, scope: "regular" }, () => { + if (chrome.runtime.lastError) { + console.warn("proxy set error:", chrome.runtime.lastError); + } + resolve(); + }); + }); + await updateBadge(profile); + await updateIcon(profile); +} + +function buildProxyConfig(profile) { + if (profile.type === "direct") { + return { mode: "direct" }; + } + if (profile.type === "system") { + return { mode: "system" }; + } + if (profile.type === "auto_detect") { + return { mode: "auto_detect" }; + } + if (profile.type === "pac") { + const url = (profile.pacUrl || "").trim(); + if (url) { + return { mode: "pac_script", pacScript: { url } }; + } + return { mode: "direct" }; + } + if (profile.type === "fixed") { + const singleProxy = { + scheme: profile.scheme || "http", + host: profile.host, + port: Number(profile.port) || 0, + }; + if (!singleProxy.host || !singleProxy.port) { + // 避免设置非法 fixed 导致联网异常 + return { mode: "direct" }; + } + const bypassList = Array.isArray(profile.bypassList) + ? profile.bypassList + : []; + return { + mode: "fixed_servers", + rules: { singleProxy, bypassList }, + }; + } + return { mode: "direct" }; +} + +async function updateBadge(profile) { + // 仅显示图标,不显示任何徽章文字 + if (chrome.action?.setBadgeText) { + await chrome.action.setBadgeText({ text: "" }); + } + if (chrome.action?.setBadgeBackgroundColor) { + await chrome.action.setBadgeBackgroundColor({ color: "#00000000" }); + } +} + +async function updateIcon(profile) { + if (!chrome.action?.setIcon) return; + const isProxyOn = profile.type !== "direct"; + const path = isProxyOn ? ICON_FALLBACK_PNG.open : ICON_FALLBACK_PNG.close; + try { + await chrome.action.setIcon({ path }); + } catch (e) { + console.warn("setIcon failed", e); + } +} + +// 已移除 SVG 栅格化与程序化绘制,专用 PNG 路径作为图标资源 + +function ensureProfiles(existing) { + const current = Array.isArray(existing) ? [...existing] : []; + const byId = new Map(current.map((p) => [p.id, p])); + const need = [ + "direct", + "system", + "auto", + "local-http", + "local-socks5", + "pac", + "custom", + ]; + let changed = false; + for (const id of need) { + if (!byId.has(id)) { + const def = DEFAULT_PROFILES.find((p) => p.id === id); + if (def) { + current.push(def); + byId.set(id, def); + changed = true; + } + } + } + // 修补关键字段 + const custom = byId.get("custom"); + if (custom) { + if (!custom.scheme) (custom.scheme = "http"), (changed = true); + if (typeof custom.port !== "number") + (custom.port = Number(custom.port) || 0), (changed = true); + if (!Array.isArray(custom.bypassList)) + (custom.bypassList = ["localhost", "127.0.0.1"]), (changed = true); + custom.type = "fixed"; + } + const auto = byId.get("auto"); + if (auto && auto.type !== "auto_detect") { + auto.type = "auto_detect"; + changed = true; + } + return changed ? current : current; +} diff --git a/icons/close-16.png b/icons/close-16.png new file mode 100644 index 0000000..4c811d4 Binary files /dev/null and b/icons/close-16.png differ diff --git a/icons/close-32.png b/icons/close-32.png new file mode 100644 index 0000000..7dc9786 Binary files /dev/null and b/icons/close-32.png differ diff --git a/icons/close.svg b/icons/close.svg new file mode 100644 index 0000000..7c92594 --- /dev/null +++ b/icons/close.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icons/open-16.png b/icons/open-16.png new file mode 100644 index 0000000..71861f1 Binary files /dev/null and b/icons/open-16.png differ diff --git a/icons/open-32.png b/icons/open-32.png new file mode 100644 index 0000000..45b5d98 Binary files /dev/null and b/icons/open-32.png differ diff --git a/icons/open.svg b/icons/open.svg new file mode 100644 index 0000000..654ab97 --- /dev/null +++ b/icons/open.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..679f5b8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,14 @@ +{ + "manifest_version": 3, + "name": "Proxy Switcher", + "version": "1.0.1", + "description": "一键切换 Chrome 代理", + "permissions": ["proxy", "storage"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Proxy Switcher" + } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..0bc74a5 --- /dev/null +++ b/popup.css @@ -0,0 +1,158 @@ +:root { + --bg: #ffffff; + --card: #f8fafc; + --text: #0f172a; + --muted: #64748b; + --line: #e2e8f0; + --accent: #6366f1; + --accent-2: #3b82f6; + --focus: rgba(99, 102, 241, 0.35); + --shadow: 0 6px 20px rgba(2, 6, 23, 0.06), 0 1px 3px rgba(2, 6, 23, 0.06); + --radius: 12px; + --radius-sm: 10px; + --ring: 0 0 0 3px var(--focus); +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); +} +body { + font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} + +.popup { + width: 340px; + max-width: 380px; + min-width: 320px; + padding: 14px 14px 12px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.title { + display: flex; + align-items: center; + gap: 8px; +} +.title h1 { + font-size: 14px; + margin: 0; + font-weight: 600; +} +.logo { + width: 18px; + height: 18px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); +} + +/* 移除未使用的标签页样式 */ + +.main { + display: grid; + gap: 12px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.field { + display: grid; + gap: 6px; +} +.field-label { + font-size: 12px; + color: var(--muted); +} +.hint { + color: var(--muted); + font-size: 12px; +} + +select, +input[type="text"], +input[type="number"] { + padding: 6px 8px; + font-size: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.text-btn { + border: 0; + background: transparent; + color: var(--accent); + font-weight: 600; + cursor: pointer; + padding: 6px 8px; + border-radius: 8px; +} +.text-btn:hover { + background: rgba(99, 102, 241, 0.1); +} +.text-btn:focus-visible { + outline: none; + box-shadow: var(--ring); +} + +/* 自定义下拉包装 */ +.select { + position: relative; + display: inline-flex; + align-items: center; + background: #fff; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} +.select:focus-within { + border-color: var(--accent); + box-shadow: var(--ring); +} +.select select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + border: 0; + outline: none; + padding: 6px 26px 6px 8px; + background: transparent; + font-size: 12px; +} +.select .chevron { + position: absolute; + right: 8px; + color: var(--muted); + pointer-events: none; + transition: transform 0.2s ease, color 0.2s ease; +} +.select.open .chevron { + color: var(--accent); + transform: translateY(1px) rotate(180deg); +} + +/* 移除未使用的 Quick Save 样式 */ +/* 移除未使用的按钮/状态样式 */ + +/* 移除未使用的 Toast 样式 */ + +/* 移除未使用的动画 */ diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..e4ca8ac --- /dev/null +++ b/popup.html @@ -0,0 +1,125 @@ + + + + + + Proxy Switcher + + + + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..835bf49 --- /dev/null +++ b/popup.js @@ -0,0 +1,123 @@ +// ========== 代理切换逻辑 ========== +const els = { + profileSelect: document.getElementById("profileSelect"), + applyBtn: document.getElementById("applyBtn"), + pacBlock: document.getElementById("pacBlock"), + pacUrl: document.getElementById("pacUrl"), + savePac: document.getElementById("savePac"), + customBlock: document.getElementById("customBlock"), + customScheme: document.getElementById("customScheme"), + customHost: document.getElementById("customHost"), + customPort: document.getElementById("customPort"), + customBypass: document.getElementById("customBypass"), + saveCustom: document.getElementById("saveCustom"), + // resetBtn: document.getElementById("resetBtn"), +}; + +// 下拉箭头展开/收起状态同步 +function bindSelectOpenState(selectEl) { + if (!selectEl) return; + const wrapper = selectEl.closest(".select"); + if (!wrapper) return; + // 点击时认为打开 + selectEl.addEventListener("mousedown", () => wrapper.classList.add("open")); + // 获得焦点也可视为打开 + selectEl.addEventListener("focus", () => wrapper.classList.add("open")); + // 失焦或变更后关闭 + selectEl.addEventListener("blur", () => wrapper.classList.remove("open")); + selectEl.addEventListener("change", () => wrapper.classList.remove("open")); +} + +bindSelectOpenState(els.profileSelect); +bindSelectOpenState(els.customScheme); + +init(); + +async function init() { + const state = await sendMessage({ type: "getState" }); + const profiles = state.profiles || []; + const activeProfileId = state.activeProfileId || "direct"; + + // 填充下拉 + els.profileSelect.innerHTML = ""; + for (const p of profiles) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = p.name; + if (p.id === activeProfileId) opt.selected = true; + els.profileSelect.appendChild(opt); + } + + toggleBlocks(); + + // 回填 PAC、自定义 + const pacProfile = profiles.find((p) => p.id === "pac"); + if (els.pacUrl) els.pacUrl.value = pacProfile?.pacUrl || ""; + + const customProfile = profiles.find((p) => p.id === "custom"); + if (customProfile) { + if (els.customScheme) + els.customScheme.value = customProfile.scheme || "http"; + if (els.customHost) els.customHost.value = customProfile.host || ""; + if (els.customPort) els.customPort.value = customProfile.port || ""; + if (els.customBypass) + els.customBypass.value = (customProfile.bypassList || []).join(", "); + } +} + +els.profileSelect?.addEventListener("change", toggleBlocks); + +function toggleBlocks() { + const mode = els.profileSelect?.value; + const isPac = mode === "pac"; + const isCustom = mode === "custom"; + if (els.pacBlock) els.pacBlock.style.display = isPac ? "block" : "none"; + if (els.customBlock) + els.customBlock.style.display = isCustom ? "block" : "none"; +} + +els.applyBtn?.addEventListener("click", async () => { + const profileId = els.profileSelect.value; + if (profileId === "custom") { + await sendMessage({ + type: "updateCustomProxy", + scheme: els.customScheme?.value, + host: els.customHost?.value.trim(), + port: Number(els.customPort?.value) || 0, + bypassList: (els.customBypass?.value || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }); + } + await sendMessage({ type: "applyProfileById", profileId }); + window.close(); +}); + +els.savePac?.addEventListener("click", async () => { + const pacUrl = (els.pacUrl?.value || "").trim(); + await sendMessage({ type: "updatePacUrl", pacUrl }); +}); + +els.saveCustom?.addEventListener("click", async () => { + await sendMessage({ + type: "updateCustomProxy", + scheme: els.customScheme?.value, + host: els.customHost?.value.trim(), + port: Number(els.customPort?.value) || 0, + bypassList: (els.customBypass?.value || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }); +}); + +// 重置按钮已移除,如需恢复可取消上方注释并启用以下代码 +// els.resetBtn?.addEventListener("click", async () => { +// await sendMessage({ type: "resetProfiles" }); +// await init(); +// }); + +function sendMessage(msg) { + return new Promise((resolve) => chrome.runtime.sendMessage(msg, resolve)); +}