From 4b0df8c7062d39c39228c595005b4115be90f077 Mon Sep 17 00:00:00 2001 From: go0p <38928800+Go0p@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:51:33 +0800 Subject: [PATCH] feat(browser): expose proxy state and browser identity (#9) * feat(proxy): reflect and release browser proxy control * feat(browser): report browser product identity --- scripts/verify-proxy-control.mjs | 102 ++++++++++++++++++ src/app/background/handlers/proxy.ts | 4 + src/app/background/index.ts | 11 +- src/entrypoints/options/App.tsx | 3 +- src/entrypoints/popup/App.css | 4 +- .../popup/views/OverviewQuickView.tsx | 5 +- .../popup/views/ProxyQuickView.tsx | 14 ++- src/entrypoints/ytray-bootstrap/main.ts | 3 + src/features/engine-bridge/service.test.ts | 23 ++++ src/features/engine-bridge/service.ts | 25 ++++- src/features/floating-panel/FloatingPanel.tsx | 8 +- src/features/proxy/service.test.ts | 35 +++++- src/features/proxy/service.ts | 86 ++++++++++++++- src/features/proxy/ui/AutoSwitchView.tsx | 4 +- src/features/proxy/ui/ProxyProfilesView.tsx | 10 +- src/features/proxy/ui/ProxyStatusBar.tsx | 60 +++++++++++ src/features/proxy/ui/proxy-status.css | 22 ++++ src/platform/storage/state.ts | 14 ++- src/protocol/extension.test.ts | 10 ++ src/protocol/extension.ts | 14 ++- src/types/messages.ts | 6 +- src/types/models.ts | 10 ++ 22 files changed, 441 insertions(+), 32 deletions(-) create mode 100644 scripts/verify-proxy-control.mjs create mode 100644 src/features/proxy/ui/ProxyStatusBar.tsx create mode 100644 src/features/proxy/ui/proxy-status.css diff --git a/scripts/verify-proxy-control.mjs b/scripts/verify-proxy-control.mjs new file mode 100644 index 0000000..83fdb7a --- /dev/null +++ b/scripts/verify-proxy-control.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { resolveChromiumPath } from './resolve-chromium.mjs'; + +// Uses an isolated profile; never changes the user's browser or system proxy. +const profile = await mkdtemp(join(tmpdir(), 'yakit-proxy-control-')); +const extension = resolve('.output/chrome-mv3'); +const context = await chromium.launchPersistentContext(profile, { + executablePath: await resolveChromiumPath(), headless: true, + viewport: { width: 390, height: 640 }, reducedMotion: 'reduce', + args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`, '--proxy-server=http://127.0.0.1:18083'], +}); +try { + const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker'); + const id = new URL(worker.url()).host; + const page = await context.newPage(); + await page.goto(`chrome-extension://${id}/ytray-bootstrap.html?manager=ytray&instanceId=proxy-test&badge=A&startupProxy=${encodeURIComponent('http://127.0.0.1:18083')}&target=chrome://version`); + await page.waitForURL('chrome://version/'); + await page.goto(`chrome-extension://${id}/options.html`); + const call = async (action, payload) => { + const response = await page.evaluate(({ action, payload }) => chrome.runtime.sendMessage({ action, payload }), { action, payload }); + assert.equal(response.ok, true, response.error); + return response.data; + }; + const launch = await call('proxy.status'); + assert.equal((await call('state.get')).startupProxy, 'http://127.0.0.1:18083'); + assert.equal(launch.followingStartup, true); + assert.equal(launch.control, 'controllable_by_this_extension'); + assert.equal(launch.activeProfileId, undefined); + assert.match(launch.label, /18083/); + await call('proxy.switch', { id: 'direct' }); + assert.equal((await call('proxy.status')).activeProfileId, 'direct'); + await call('proxy.switch', { id: 'yakit-mitm' }); + assert.equal((await call('proxy.status')).activeProfileId, 'yakit-mitm'); + await call('proxy.auto.apply'); + assert.equal((await call('proxy.status')).activeProfileId, 'auto'); + await call('proxy.switch', { id: 'system' }); + assert.equal((await call('proxy.status')).activeProfileId, 'system'); + await call('proxy.release'); + assert.deepEqual(await call('proxy.status'), launch); + await page.goto(`chrome-extension://${id}/popup.html`); + await page.getByRole('button', { name: '代理', exact: true }).click(); + await page.getByRole('status').filter({ hasText: '实际代理' }).getByText('http://127.0.0.1:18083', { exact: true }).waitFor(); + assert.equal(await page.getByRole('radio', { name: /直接连接/ }).getAttribute('aria-checked'), 'false'); + const follow = page.getByRole('radio', { name: /跟随启动配置/ }); + assert.equal(await follow.getAttribute('aria-checked'), 'true'); + await page.evaluate(() => { + const startup = document.querySelector('.startup-proxy-option'); + const ordinary = document.querySelector('.popup-proxy-list > button'); + for (const [a, b] of [[startup.querySelector('.startup-proxy-icon'), ordinary.querySelector('.popup-mode-icon')], [startup.querySelector('strong'), ordinary.querySelector('strong')], [startup.querySelector('small'), ordinary.querySelector('small')]]) { + if (Math.abs(a.getBoundingClientRect().x - b.getBoundingClientRect().x) > 1) throw new Error('Proxy mode columns are not aligned'); + } + }); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.getByRole('radio', { name: /直接连接/ }).click(); + await page.locator('.popup-global-notice').waitFor(); + await page.evaluate(() => { + const notice = document.querySelector('.popup-global-notice'); + const animation = notice.getAnimations()[0]; + if (!animation) throw new Error('Expected notice entrance animation'); + animation.pause(); + for (const time of [0, 40, 80, 159, 200]) { + animation.currentTime = time; + const rect = notice.getBoundingClientRect(); + const parent = notice.offsetParent.getBoundingClientRect(); + if (Math.abs(rect.x + rect.width / 2 - (parent.x + parent.width / 2)) > 1) { + throw new Error(`Notice is not centered at animation time ${time}ms`); + } + } + animation.finish(); + }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.waitForFunction(() => document.querySelector('[aria-label="实际代理状态"]')?.textContent === '实际代理直接连接'); + assert.equal(await follow.getAttribute('aria-checked'), 'false'); + await follow.click(); + await page.waitForFunction(() => document.querySelector('.startup-proxy-option [role="radio"]')?.getAttribute('aria-checked') === 'true'); + assert.deepEqual(await call('proxy.status'), launch); + await page.getByRole('button', { name: '解释跟随启动配置' }).focus(); + await page.getByRole('tooltip').waitFor(); + assert.match(await page.getByRole('tooltip').innerText(), /切换后使用浏览器启动时的网络配置/); + assert.doesNotMatch(await page.getByRole('tooltip').innerText(), /清除|接管/); + await page.getByRole('radio', { name: /跟随启动配置/ }).focus(); + await page.mouse.move(4, 4); + await mkdir('.artifacts/proxy', { recursive: true }); + await page.screenshot({ path: '.artifacts/proxy/launch-proxy.png' }); + // External settings changes must update an already-open view, without storage mutations. + await page.evaluate(() => chrome.proxy.settings.set({ scope: 'regular', value: { mode: 'direct' } })); + await page.getByRole('status').filter({ hasText: '实际代理' }).getByText('直接连接', { exact: true }).waitFor(); + assert.equal(await page.getByRole('radio', { name: /直接连接/ }).getAttribute('aria-checked'), 'false'); + await page.goto(`chrome-extension://${id}/ytray-bootstrap.html?manager=ytray&instanceId=direct-test&badge=A&startupProxy=direct&target=chrome://version`); + await page.waitForURL('chrome://version/'); + await page.goto(`chrome-extension://${id}/popup.html`); + await page.getByRole('button', { name: '代理', exact: true }).click(); + assert.equal(await page.getByRole('radio', { name: /跟随启动配置/ }).count(), 0); + console.log('PASS: launch proxy → direct → fixed → PAC → system → release; live status; stale selection not marked active.'); +} finally { + await context.close(); + await rm(profile, { recursive: true, force: true }); +} diff --git a/src/app/background/handlers/proxy.ts b/src/app/background/handlers/proxy.ts index a521a5c..a705a87 100644 --- a/src/app/background/handlers/proxy.ts +++ b/src/app/background/handlers/proxy.ts @@ -18,6 +18,8 @@ import { saveProxyRuleSource, setProxyAuthPassword, switchProxy, + getProxyStatus, + releaseProxy, } from '@/features/proxy/service'; import { updateState } from '@/platform/storage/state'; @@ -26,6 +28,8 @@ export const handleProxyRequest: BackgroundRequestHandler = async (request) => { case 'proxy.save': return ok(await saveProxyProfile(request.payload)); case 'proxy.delete': return ok(await removeProxyProfile(request.payload.id)); case 'proxy.switch': return ok(await switchProxy(request.payload.id)); + case 'proxy.status': return ok(await getProxyStatus()); + case 'proxy.release': return ok(await releaseProxy()); case 'proxy.rule.save': { const rule = request.payload; return ok(await updateState((state) => { diff --git a/src/app/background/index.ts b/src/app/background/index.ts index d03ff10..7246862 100644 --- a/src/app/background/index.ts +++ b/src/app/background/index.ts @@ -428,7 +428,15 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. } const state = await updateState((current) => ({ ...current, - bridge: { ...current.bridge, managedInstance: request.payload }, + startupProxy: request.payload.startupProxy, + bridge: { + ...current.bridge, + browserName: request.payload.browserName, + browserVersion: request.payload.browserVersion, + managedInstance: { + manager: request.payload.manager, instanceId: request.payload.instanceId, badge: request.payload.badge, + }, + }, })); await syncManagedInstanceBadge(state.bridge.managedInstance); if (state.bridge.autoConnect && state.bridge.pairedEngine) { @@ -512,6 +520,7 @@ export function runBackground(): void { ) => { if ([ 'bridge.status.changed', + 'proxy.status.changed', 'bridge.pairing.status.changed', 'network.capture.changed', 'deep.capture.changed', diff --git a/src/entrypoints/options/App.tsx b/src/entrypoints/options/App.tsx index 4d7d8a1..f2dd33f 100644 --- a/src/entrypoints/options/App.tsx +++ b/src/entrypoints/options/App.tsx @@ -16,6 +16,7 @@ import { import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation'; import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView'; import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView'; +import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar'; import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView'; import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace'; import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace'; @@ -337,7 +338,7 @@ function ActivityLog({ run, busy }: { run: (task: () => Promise, success?: } function Overview({ state, bridge, tab, navigate, run, busy }: { state: ExtensionState; bridge: BridgeStatus; tab?: ActiveTabInfo; navigate: (value: Section) => void; run: (task: () => Promise, success?: string) => Promise; busy: boolean }) { - const activeProxy = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || (state.activeProxyId === 'auto' ? '自动切换' : '未知'); + const activeProxy = useProxyStatus(state).label; const [runtime, setRuntime] = useState({ state: 'idle', updatedAt: Date.now(), actions: [] }); const [network, setNetwork] = useState(); const [loginContext, setLoginContext] = useState(); diff --git a/src/entrypoints/popup/App.css b/src/entrypoints/popup/App.css index b4365ef..8ffc8a5 100644 --- a/src/entrypoints/popup/App.css +++ b/src/entrypoints/popup/App.css @@ -206,7 +206,9 @@ .popup-footer { margin-top: auto; padding: 10px 14px 12px; border-top: 1px solid var(--border); background: var(--surface); } .popup-capture { width: 100%; height: 36px; border-radius: 7px; font-size: var(--text-md); box-shadow: 0 1px 0 color-mix(in srgb, var(--primary-strong) 55%, transparent); } .popup-notice { display: block; margin-top: 6px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; text-align: center; white-space: nowrap; text-overflow: ellipsis; } -.popup-global-notice { position: absolute; z-index: 20; left: 50%; bottom: 54px; max-width: calc(100% - 28px); padding: 7px 11px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--foreground); color: var(--surface); box-shadow: var(--shadow-md); font-size: var(--text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; transform: translateX(-50%); animation: popup-content-in .16s ease-out; } +/* Keep horizontal centering independent of the entrance animation's transform. */ +.popup-global-notice { position: absolute; z-index: 20; left: 50%; bottom: 54px; max-width: calc(100% - 28px); padding: 7px 11px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--foreground); color: var(--surface); box-shadow: var(--shadow-md); font-size: var(--text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; translate: -50% 0; animation: popup-content-in .16s ease-out; } +@media (prefers-reduced-motion: reduce) { .popup-global-notice { animation: none; } } .popup-loading { width: 390px; height: 230px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); background: var(--background); font-size: var(--text-md); } .spin { animation: spin .8s linear infinite; } diff --git a/src/entrypoints/popup/views/OverviewQuickView.tsx b/src/entrypoints/popup/views/OverviewQuickView.tsx index 7fc9c72..6c12fa6 100644 --- a/src/entrypoints/popup/views/OverviewQuickView.tsx +++ b/src/entrypoints/popup/views/OverviewQuickView.tsx @@ -2,6 +2,7 @@ import { Braces, ChevronRight, Cookie, Network, Radio, ShieldCheck, UserRoundCog import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { request } from '@/platform/messaging/runtime'; +import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar'; import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities'; import type { ActiveTabInfo, ExtensionState, UserAgentResolution } from '@/types/models'; @@ -25,9 +26,7 @@ interface OverviewQuickViewProps { export function OverviewQuickView({ state, tab, grantActive, busy, run, setState, cookieCount, uaResolution, onNavigate, onOpenContext, onCapture, }: OverviewQuickViewProps) { - const activeProxy = state.activeProxyId === 'auto' - ? '自动切换' - : state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || '未选择'; + const activeProxy = useProxyStatus(state).label; const targetAvailable = Boolean(tab?.url?.startsWith('http')); return
diff --git a/src/entrypoints/popup/views/ProxyQuickView.tsx b/src/entrypoints/popup/views/ProxyQuickView.tsx index 0f6bb67..16a60ff 100644 --- a/src/entrypoints/popup/views/ProxyQuickView.tsx +++ b/src/entrypoints/popup/views/ProxyQuickView.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { AlertCircle, Check, ExternalLink, Globe2, LoaderCircle, Network, Route } from 'lucide-react'; import { request } from '@/platform/messaging/runtime'; import type { ActiveTabInfo, ExtensionState, ProxyProfile, ProxyRulePreview } from '@/types/models'; +import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar'; type RunTask = (task: () => Promise, success?: string) => Promise; @@ -43,10 +44,11 @@ function routeKindLabel(preview?: ProxyRulePreview): string { } export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: ProxyQuickViewProps) { + const status = useProxyStatus(state); const [preview, setPreview] = useState(); const currentHostname = hostname(tab?.url); - const autoActive = state.activeProxyId === 'auto'; - const activeProfile = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId); + const autoActive = status.activeProfileId === 'auto'; + const activeProfile = state.proxyProfiles.find((profile) => profile.id === status.activeProfileId); const routableProfiles = useMemo( () => state.proxyProfiles.filter((profile) => profile.kind === 'direct' || profile.kind === 'fixed_servers'), [state.proxyProfiles], @@ -130,7 +132,7 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: }; const effectiveProfile = state.proxyProfiles.find((profile) => profile.id === preview?.effectiveProfileId); - const activeModeName = autoActive ? '自动切换' : activeProfile?.name || '未选择'; + const activeModeName = autoActive ? '自动切换' : status.label; const siteHint = !autoActive ? `当前使用“${activeModeName}”;选择网站出口后将启用自动切换。` : siteTarget === AUTOMATIC_TARGET @@ -142,13 +144,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: const routeKindText = autoActive ? routeKindLabel(preview) : '全局模式'; return
+ {currentHostname ?
当前站点{currentHostname}
{routeKindText}
- {routeLabel}{routeProfile?.name || '—'} + {routeLabel}{routeProfile?.name || status.label}
@@ -168,13 +171,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
浏览器模式全局切换,不会创建站点规则{activeModeName}
+ {state.proxyProfiles.map((profile) => { - const active = state.activeProxyId === profile.id; + const active = status.activeProfileId === profile.id; return
+ {state.proxyProfiles.map((profile) => ( - ))} - +
diff --git a/src/features/proxy/service.test.ts b/src/features/proxy/service.test.ts index 497e310..19aed59 100644 --- a/src/features/proxy/service.test.ts +++ b/src/features/proxy/service.test.ts @@ -7,6 +7,7 @@ const harness = vi.hoisted(() => ({ state: undefined as ExtensionState | undefined, sourceRules: new Map(), proxySet: vi.fn(async (_details: unknown) => undefined), + proxyClear: vi.fn(async (_details: unknown) => undefined), proxyGet: vi.fn(async (_details: unknown) => ({ value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension', })), @@ -24,7 +25,7 @@ const harness = vi.hoisted(() => ({ vi.mock('wxt/browser', () => ({ browser: { - proxy: { settings: { get: harness.proxyGet, set: harness.proxySet } }, + proxy: { settings: { get: harness.proxyGet, set: harness.proxySet, clear: harness.proxyClear } }, storage: { session: { get: harness.sessionGet, set: harness.sessionSet }, onChanged: { addListener: vi.fn() }, @@ -60,6 +61,7 @@ vi.mock('./repository', () => ({ import { applyProxyRules, importProxyConfiguration, refreshProxyRuleSource, removeProxyProfile, routeCurrentSite, saveProxyProfile, saveProxyRuleSource, setProxyAuthPassword, switchProxy, + getProxyStatus, releaseProxy, proxyConfigMatches, } from './service'; function baseState(): ExtensionState { @@ -120,12 +122,41 @@ describe('proxy service', () => { harness.state = baseState(); harness.sourceRules.clear(); vi.clearAllMocks(); - harness.proxySet.mockResolvedValue(undefined); + harness.proxySet.mockImplementation(async (details) => { + harness.proxyGet.mockResolvedValue({ value: (details as any).value, levelOfControl: 'controlled_by_this_extension' }); + }); harness.proxyGet.mockResolvedValue({ value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension', }); }); + it('distinguishes launch proxy from stored selection, verifies application and releases without selecting direct', async () => { + harness.proxyGet.mockResolvedValue({ value: { mode: 'fixed_servers', rules: { singleProxy: { host: '127.0.0.1', port: 8083 } } } as any, levelOfControl: 'controllable_by_this_extension' }); + expect(await getProxyStatus()).toEqual({ control: 'controllable_by_this_extension', label: 'http://127.0.0.1:8083', activeProfileId: undefined, followingStartup: true }); + harness.state!.startupProxy = 'http://127.0.0.1:9999'; + expect((await getProxyStatus()).followingStartup).toBe(false); + harness.state!.startupProxy = 'http://127.0.0.1:8083'; + expect((await getProxyStatus()).followingStartup).toBe(true); + await saveProxyProfile({ ...baseState().proxyProfiles[0] }); + expect(harness.proxySet).not.toHaveBeenCalled(); + await switchProxy('custom'); + expect((await getProxyStatus()).activeProfileId).toBe('custom'); + harness.proxyClear.mockImplementation(async () => { + harness.proxyGet.mockResolvedValue({ value: { mode: 'fixed_servers' }, levelOfControl: 'controllable_by_this_extension' }); + }); + expect((await releaseProxy()).activeProxyId).toBe(''); + expect(harness.proxyClear).toHaveBeenCalledWith({ scope: 'regular' }); + expect(harness.proxySet).toHaveBeenCalledTimes(1); + harness.proxySet.mockResolvedValue(undefined); + await expect(switchProxy('direct')).rejects.toThrow('实际配置或控制权'); + expect(harness.state?.activeProxyId).toBe(''); + harness.state!.activeProxyId = 'custom'; + expect((await removeProxyProfile('custom')).activeProxyId).toBe(''); + harness.proxyGet.mockResolvedValue({ value: { mode: 'direct' }, levelOfControl: 'not_controllable' }); + await expect(switchProxy('direct')).rejects.toThrow('管理策略'); + expect(proxyConfigMatches({ mode: 'fixed_servers', rules: { singleProxy: { host: 'a', port: 80 } } }, { mode: 'fixed_servers', rules: { singleProxy: { scheme: 'http', host: 'a', port: 80 }, bypassList: [] } })).toBe(true); + }); + it('applies automatic routing and commits the exact PAC revision', async () => { const rules: NormalizedProxyRule[] = Array.from({ length: 2_000 }, (_, ordinal) => ({ sourceId: 'source', ordinal, condition: { type: 'host_wildcard', value: `*.d${ordinal}.example` }, diff --git a/src/features/proxy/service.ts b/src/features/proxy/service.ts index 69dc2da..3346b07 100644 --- a/src/features/proxy/service.ts +++ b/src/features/proxy/service.ts @@ -2,7 +2,7 @@ import { browser } from 'wxt/browser'; import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY } from '@/protocol/storage'; import type { ExtensionState, ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePage, ProxyRulePreview, - ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput, + ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput, ProxyStatus, } from '@/types/models'; import { getState, updateState } from '@/platform/storage/state'; import { @@ -32,6 +32,10 @@ const authPasswords = new Map(); const sourceRefreshes = new Map }>(); let proxyState: ExtensionState | undefined; +browser.proxy?.settings?.onChange?.addListener(() => { + void browser.runtime.sendMessage({ action: 'proxy.status.changed' }).catch(() => undefined); +}); + function isFirefox(): boolean { return Boolean(import.meta.env.FIREFOX); } @@ -119,7 +123,7 @@ async function assertProxyControl(): Promise { throw new Error('浏览器代理正由其他扩展控制,请先停用其他代理扩展后重试'); } if (current.levelOfControl === 'not_controllable') { - throw new Error('浏览器代理受系统策略或启动参数控制,当前扩展无法修改'); + throw new Error('浏览器报告代理不可由扩展控制,请检查强制管理策略;普通启动代理参数不代表锁定'); } } @@ -130,18 +134,89 @@ async function setPacScript(pacScript: string): Promise { value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig, scope: 'regular', }); + await verifyProxyControl({ proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` }); return; } await browser.proxy.settings.set({ value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } }, scope: 'regular', }); + await verifyProxyControl({ mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } }); } async function setBrowserProxyProfile(profile: ProxyProfile): Promise { await assertProxyControl(); const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile); await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' }); + await verifyProxyControl(value); +} + +async function verifyProxyControl(expected: object): Promise { + const actual = await browser.proxy.settings.get({ incognito: false }); + if (actual.levelOfControl !== 'controlled_by_this_extension' || !proxyConfigMatches(actual.value, expected)) { + throw new Error('代理设置已提交,但实际配置或控制权与预期不符,请刷新实际代理状态后重试'); + } +} + +export async function getProxyStatus(state?: ExtensionState): Promise { + if (!browser.proxy?.settings) return { control: 'unavailable', label: '浏览器不支持代理 API' }; + const actual = await browser.proxy.settings.get({ incognito: false }); + const value = actual.value as Browser.proxy.ProxyConfig & { proxyType?: string }; + const control = actual.levelOfControl; + const mode = value.mode || value.proxyType; + const server = value.rules?.singleProxy; + let label = server ? `${server.scheme || 'http'}://${server.host}:${server.port || (server.scheme === 'https' ? 443 : server.scheme?.startsWith('socks') ? 1080 : 80)}` + : ({ direct: '直接连接', none: '直接连接', system: '系统代理', pac_script: 'PAC 自动代理', autoConfig: 'PAC 自动代理', fixed_servers: '固定代理(按协议)', manual: '手动代理', auto_detect: '自动检测' }[mode] || '未知代理模式'); + let activeProfileId: string | undefined; + const current = state || await getState(); + let followingStartup = control === 'controllable_by_this_extension'; + if (followingStartup && current.startupProxy) { + try { + const endpoint = current.startupProxy === 'direct' ? undefined : new URL(current.startupProxy); + followingStartup = proxyConfigMatches(value, endpoint ? chromeProxyValue({ + id: '', name: '', kind: 'fixed_servers', scheme: endpoint.protocol === 'https:' ? 'https' : 'http', + host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), bypass: [], + }) : { mode: 'direct' }); + } catch { followingStartup = false; } + } + if (control === 'controlled_by_this_extension') { + const profile = current.proxyProfiles.find((item) => item.id === current.activeProxyId); + if (profile && proxyConfigMatches(value, isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile))) { + activeProfileId = profile.id; + } else if (current.activeProxyId === 'auto') { + const artifact = current.proxyRuntime.revision ? await getCompiledArtifact(current.proxyRuntime.revision) : undefined; + if (artifact && (value.pacScript?.data === artifact.pacScript + || (value as unknown as { autoConfigUrl?: string }).autoConfigUrl === `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(artifact.pacScript)}`)) activeProfileId = 'auto'; + } + if (activeProfileId) label = activeProfileId === 'auto' ? '自动切换(PAC)' : profile!.name === label ? label : `${profile!.name} · ${label}`; + } + return { control, label, activeProfileId, followingStartup }; +} + +// Chrome may add default ports and expand singleProxy into per-protocol entries on readback. +export function proxyConfigMatches(actual: unknown, expected: unknown): boolean { + const a = actual as Record; + const e = expected as Record; + if (e.mode === 'fixed_servers') { + if (a.mode !== e.mode) return false; + const server = (value: any) => value && `${value.scheme || 'http'}://${String(value.host).toLowerCase()}:${value.port || (value.scheme === 'https' ? 443 : value.scheme?.startsWith('socks') ? 1080 : 80)}`; + const wanted = server(e.rules.singleProxy); + const rules = a.rules || {}; + const matches = rules.singleProxy ? server(rules.singleProxy) === wanted + : ['proxyForHttp', 'proxyForHttps', 'proxyForFtp', 'fallbackProxy'].every((key) => server(rules[key]) === wanted); + return matches && JSON.stringify([...(rules.bypassList || [])].sort()) === JSON.stringify([...(e.rules.bypassList || [])].sort()); + } + if (e.mode === 'pac_script') return a.mode === e.mode && a.pacScript?.data === e.pacScript?.data && a.pacScript?.url === e.pacScript?.url; + return Object.keys(e).every((key) => a[key] === e[key]); +} + +export async function releaseProxy(): Promise { + return updateState(async (current) => { + await browser.proxy.settings.clear({ scope: 'regular' }); + const actual = await browser.proxy.settings.get({ incognito: false }); + if (actual.levelOfControl === 'controlled_by_this_extension') throw new Error('浏览器尚未撤销本扩展的代理接管'); + return { ...current, activeProxyId: '' }; + }); } async function compilationInput(state: ExtensionState, withRules = true): Promise { @@ -213,17 +288,17 @@ export async function saveProxyProfile(profile: ProxyProfile): Promise item.id !== canonical.id), canonical], }); - if (current.activeProxyId === canonical.id) await setBrowserProxyProfile(canonical); + if ((await getProxyStatus(current)).activeProfileId === canonical.id) await setBrowserProxyProfile(canonical); return next; }); } export async function removeProxyProfile(profileId: string): Promise { - const saved = await updateState((current) => { + const saved = await updateState(async (current) => { const profile = current.proxyProfiles.find((item) => item.id === profileId); if (!profile) throw new Error('代理配置不存在'); if (RESERVED_PROXY_PROFILE_IDS.has(profileId) || profile.builtin) throw new Error('内置代理出口不能删除'); - if (current.activeProxyId === profileId) throw new Error('该出口正在使用,请先切换到其他出口'); + if ((await getProxyStatus(current)).activeProfileId === profileId) throw new Error('该出口正在使用,请先切换到其他出口'); if (current.proxyRules.some((rule) => rule.proxyProfileId === profileId) || current.proxyRuleSources.some((source) => source.matchProfileId === profileId || source.bypassProfileId === profileId) || current.proxyRouting.defaultProfileId === profileId) { @@ -232,6 +307,7 @@ export async function removeProxyProfile(profileId: string): Promise item.id !== profileId), + activeProxyId: current.activeProxyId === profileId ? '' : current.activeProxyId, }); }); await setProxyAuthPassword(profileId, ''); diff --git a/src/features/proxy/ui/AutoSwitchView.tsx b/src/features/proxy/ui/AutoSwitchView.tsx index 2b3cdb5..5f381fd 100644 --- a/src/features/proxy/ui/AutoSwitchView.tsx +++ b/src/features/proxy/ui/AutoSwitchView.tsx @@ -10,6 +10,7 @@ import { request } from '@/platform/messaging/runtime'; import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models'; import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation'; import type { ProxyViewProps } from './types'; +import { useProxyStatus } from './ProxyStatusBar'; import './proxy-workspace.css'; const ROW_HEIGHT = 58; @@ -47,6 +48,7 @@ function conditionHint(type: ProxyConditionType): string { } export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewProps) { + const proxyStatus = useProxyStatus(state); const rules = useMemo(() => [...state.proxyRules].sort((left, right) => left.order - right.order), [state.proxyRules]); const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]); const [draft, setDraft] = useState(() => freshRule(state.proxyRules.length, tab?.url)); @@ -64,7 +66,7 @@ export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewPro const visibleCount = Math.ceil(LIST_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2; const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount); const enabledSources = state.proxyRuleSources.filter((source) => source.enabled && source.revision); - const active = state.activeProxyId === 'auto'; + const active = proxyStatus.activeProfileId === 'auto'; const save = () => run(async () => { const now = Date.now(); diff --git a/src/features/proxy/ui/ProxyProfilesView.tsx b/src/features/proxy/ui/ProxyProfilesView.tsx index 0dd58db..7f48a5f 100644 --- a/src/features/proxy/ui/ProxyProfilesView.tsx +++ b/src/features/proxy/ui/ProxyProfilesView.tsx @@ -9,6 +9,7 @@ import type { ProxyProfile } from '@/types/models'; import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation'; import type { ProxyViewProps } from './types'; import './proxy-workspace.css'; +import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from './ProxyStatusBar'; function createProfile(): ProxyProfile { return { @@ -17,6 +18,7 @@ function createProfile(): ProxyProfile { } export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) { + const status = useProxyStatus(state); const [draft, setDraft] = useState(() => state.proxyProfiles[0] || createProfile()); const [password, setPassword] = useState(''); const [passwordConfigured, setPasswordConfigured] = useState(false); @@ -65,18 +67,20 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
+
出口{state.proxyProfiles.length}
+ {state.proxyProfiles.map((profile) => )}
@@ -85,7 +89,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
{draft.builtin ? '内置出口' : '自定义出口'}

{draft.name}

- {state.activeProxyId === draft.id ? '当前生效' : '未使用'} + {status.activeProfileId === draft.id ? '当前生效' : '未确认生效'}
setDraft({ ...draft, name: event.target.value })} /> diff --git a/src/features/proxy/ui/ProxyStatusBar.tsx b/src/features/proxy/ui/ProxyStatusBar.tsx new file mode 100644 index 0000000..05215ba --- /dev/null +++ b/src/features/proxy/ui/ProxyStatusBar.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { browser } from 'wxt/browser'; +import { request } from '@/platform/messaging/runtime'; +import type { ExtensionState, ProxyStatus } from '@/types/models'; +import type { ProxyViewProps } from './types'; +import { Check, Info, Monitor } from 'lucide-react'; +import { Tooltip, TooltipProvider } from '@/components/ui/tooltip'; +import './proxy-status.css'; + +export function useProxyStatus(state: ExtensionState): ProxyStatus { + const [status, setStatus] = useState({ control: 'loading', label: '正在读取实际代理…' }); + useEffect(() => { + let revision = 0; + const refresh = async () => { + const current = ++revision; + try { + const value = await request('proxy.status'); + if (current === revision) setStatus(value); + } catch { + if (current === revision) setStatus({ control: 'unavailable', label: '实际代理读取失败' }); + } + }; + const listener = (message: unknown) => { + if ((message as { action?: string })?.action === 'proxy.status.changed') void refresh(); + }; + browser.runtime.onMessage.addListener(listener); + void refresh(); + return () => { revision++; browser.runtime.onMessage.removeListener(listener); }; + }, [state]); + return status; +} + +export function ProxyStatusBar({ status }: { status: ProxyStatus }) { + const source = status.control === 'controlled_by_this_extension' ? '本扩展控制' + : status.control === 'controlled_by_other_extensions' ? '其他扩展控制' + : status.control === 'not_controllable' ? '浏览器限制修改,请检查管理策略' + : status.control === 'controllable_by_this_extension' ? '非本扩展控制,可选择出口接管' : '状态未确认'; + return
+ 实际代理{status.label} +
; +} + +export function StartupProxyOption({ state, status, setState, run, busy }: Pick & { status: ProxyStatus }) { + if (state.bridge.managedInstance?.manager === 'ytray' && state.startupProxy === 'direct') return null; + const active = Boolean(status.followingStartup); + const manager = state.bridge.managedInstance?.manager === 'ytray' ? 'YTray' : state.bridge.managedInstance?.manager === 'yakit' ? 'Yakit' : '浏览器'; + const detail = state.startupProxy ? `${manager} · ${state.startupProxy === 'direct' ? '启动时直连' : state.startupProxy}` : `${manager}启动参数或系统默认设置`; + return
+ + + + +
; +} diff --git a/src/features/proxy/ui/proxy-status.css b/src/features/proxy/ui/proxy-status.css new file mode 100644 index 0000000..e8bd9f6 --- /dev/null +++ b/src/features/proxy/ui/proxy-status.css @@ -0,0 +1,22 @@ +.proxy-effective-status { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); flex-shrink: 0; min-width: 0; } +.proxy-effective-status small { color: var(--muted); font-size: var(--text-xs, 12px); flex-shrink: 0; } +.proxy-effective-status strong { font-size: var(--text-sm, 13px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.popup-proxy-view > .proxy-effective-status { padding: 8px 12px; background: var(--surface); } +.popup-proxy-view.popup-tool-view { overflow-y: auto; } +.startup-proxy-option { position: relative; border: 1px solid transparent; border-radius: var(--radius-md); min-width: 0; } +.startup-proxy-option.is-active { border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); background: var(--primary-soft); } +.startup-proxy-option.is-active > button:first-child { color: var(--primary-text); } +.startup-proxy-option > button { border: 0; background: transparent; color: var(--foreground); cursor: pointer; padding: 7px 8px; } +.startup-proxy-option > button:first-child { display: grid; grid-template-columns: 28px minmax(0, 1fr) 14px; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; min-width: 0; text-align: left; min-height: 41px; } +.startup-proxy-label { min-width: 0; padding-right: 28px; } +.startup-proxy-icon { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--muted-strong); } +.startup-proxy-option.is-active .startup-proxy-icon { border-color: color-mix(in srgb, var(--primary) 28%, var(--border)); color: var(--primary); } +.startup-proxy-check { display: grid; place-items: center; color: var(--primary); } +.startup-proxy-option strong, .startup-proxy-option small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.startup-proxy-option strong { font-size: var(--text-sm, 13px); font-weight: 600; line-height: 16px; } +.startup-proxy-option small { margin-top: 1px; font-size: var(--text-xs, 12px); line-height: 14px; color: var(--muted); } +.startup-proxy-option svg { flex-shrink: 0; } +.startup-proxy-option .startup-proxy-info { position: absolute; right: 30px; top: 50%; translate: 0 -50%; color: var(--muted); display: grid; place-items: center; width: 28px; height: 32px; padding: 0; } +.startup-proxy-option > button:hover { background: var(--surface-subtle); } +.startup-proxy-option > button:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; border-radius: var(--radius-md); } +.startup-proxy-option > button:disabled { cursor: default; opacity: .5; } diff --git a/src/platform/storage/state.ts b/src/platform/storage/state.ts index 53c799c..0609c89 100644 --- a/src/platform/storage/state.ts +++ b/src/platform/storage/state.ts @@ -31,7 +31,7 @@ export const DEFAULT_STATE: ExtensionState = { proxyRuleSources: [], proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' }, proxyRuntime: { dirty: false, compiledBytes: 0, manualRuleCount: 0, sourceRuleCount: 0, warnings: [] }, - activeProxyId: 'direct', + activeProxyId: '', customUserAgentProfiles: [], userAgentAssignments: [], bridge: { @@ -109,6 +109,11 @@ function normalizeManagedInstance(input: unknown): BridgeConfig['managedInstance return value as NonNullable; } +function normalizeBrowserMetadata(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string') return undefined; + return input.trim().slice(0, maxLength) || undefined; +} + function normalizeState(value: Partial): ExtensionState { const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile])); const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : []; @@ -183,14 +188,16 @@ function normalizeState(value: Partial): ExtensionState { ...(value.proxyRuntime && typeof value.proxyRuntime === 'object' ? value.proxyRuntime : {}), warnings: Array.isArray(value.proxyRuntime?.warnings) ? value.proxyRuntime.warnings.slice(0, 100) : [], }, - activeProxyId: value.activeProxyId === 'auto' || proxyProfiles.some((profile) => profile.id === value.activeProxyId) + activeProxyId: value.activeProxyId === '' || value.activeProxyId === 'auto' || proxyProfiles.some((profile) => profile.id === value.activeProxyId) ? value.activeProxyId! - : 'direct', + : '', customUserAgentProfiles: userAgentState.customUserAgentProfiles, userAgentAssignments: userAgentState.userAgentAssignments, bridge: { ...DEFAULT_STATE.bridge, ...value.bridge, + browserName: normalizeBrowserMetadata(value.bridge?.browserName, 120), + browserVersion: normalizeBrowserMetadata(value.bridge?.browserVersion, 80), managedInstance: normalizeManagedInstance(value.bridge?.managedInstance), }, floatingPanel: { @@ -259,6 +266,7 @@ export async function setState(input: ExtensionState): Promise { proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules, proxyRuleSources: state.proxyRuleSources, proxyRouting: state.proxyRouting, proxyRuntime: state.proxyRuntime, activeProxyId: state.activeProxyId, + startupProxy: state.startupProxy, }, [USER_AGENT_SETTINGS_STORAGE_KEY]: { customUserAgentProfiles: state.customUserAgentProfiles, diff --git a/src/protocol/extension.test.ts b/src/protocol/extension.test.ts index 77c8b64..9a90749 100644 --- a/src/protocol/extension.test.ts +++ b/src/protocol/extension.test.ts @@ -119,6 +119,16 @@ describe('extension request schemas', () => { }); it('validates manager-owned browser instance binding', () => { + const binding = { + manager: 'ytray', instanceId: 'instance-a', badge: 'A', + browserName: 'Chrome for Testing', browserVersion: '152.0.7977.82', + }; + for (const startupProxy of ['direct', 'http://127.0.0.1:8083', 'https://proxy.example:443']) { + expect(parseExtensionRequest({ action: 'bridge.managed-instance.bind', payload: { ...binding, startupProxy } }).action).toBe('bridge.managed-instance.bind'); + } + for (const startupProxy of ['http://user:secret@proxy.example:8083', 'http://proxy.example/path', 'javascript:alert(1)']) { + expect(() => parseExtensionRequest({ action: 'bridge.managed-instance.bind', payload: { ...binding, startupProxy } })).toThrow(); + } expect(parseExtensionRequest({ action: 'bridge.managed-instance.bind', payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' }, diff --git a/src/protocol/extension.ts b/src/protocol/extension.ts index b6a204a..7065442 100644 --- a/src/protocol/extension.ts +++ b/src/protocol/extension.ts @@ -179,6 +179,8 @@ const bridgeConfig = v.strictObject({ endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)), autoConnect: v.boolean(), installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)), + browserName: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))), + browserVersion: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80))), managedInstance: v.optional(managedInstance), pairedEngine: v.optional(v.strictObject({ engineIdentityId: id, @@ -301,6 +303,8 @@ const payloadSchemas = { 'proxy.save': proxyProfile, 'proxy.delete': v.strictObject({ id }), 'proxy.switch': v.strictObject({ id }), + 'proxy.status': noPayload, + 'proxy.release': noPayload, 'proxy.rule.save': proxyRule, 'proxy.rule.delete': v.strictObject({ id }), 'proxy.auto.apply': noPayload, @@ -471,7 +475,15 @@ const payloadSchemas = { 'metrics.get': noPayload, 'metrics.reset': noPayload, 'bridge.config.save': bridgeConfig, - 'bridge.managed-instance.bind': managedInstance, + 'bridge.managed-instance.bind': v.strictObject({ + ...managedInstance.entries, + browserName: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))), + browserVersion: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80))), + startupProxy: v.optional(v.union([v.literal('direct'), v.pipe(httpUrl, v.check((value) => { + const parsed = new URL(value); + return !parsed.username && !parsed.password && !parsed.search && !parsed.hash && parsed.pathname === '/'; + }, '启动代理只能包含协议、主机和端口'))])), + }), 'bridge.pair': noPayload, 'bridge.pair.cancel': noPayload, 'bridge.pair.status': noPayload, diff --git a/src/types/messages.ts b/src/types/messages.ts index 1271a30..018675f 100644 --- a/src/types/messages.ts +++ b/src/types/messages.ts @@ -99,6 +99,8 @@ export interface ExtensionRequestMap { 'proxy.save': { input: ProxyProfile; output: ExtensionState }; 'proxy.delete': { input: { id: string }; output: ExtensionState }; 'proxy.switch': { input: { id: string }; output: ExtensionState }; + 'proxy.status': { input: undefined; output: import('./models').ProxyStatus }; + 'proxy.release': { input: undefined; output: ExtensionState }; 'proxy.rule.save': { input: ProxyRule; output: ExtensionState }; 'proxy.rule.delete': { input: { id: string }; output: ExtensionState }; 'proxy.auto.apply': { input: undefined; output: ExtensionState }; @@ -250,7 +252,9 @@ export interface ExtensionRequestMap { 'metrics.reset': { input: undefined; output: RuntimeMetrics }; 'bridge.config.save': { input: BridgeConfig; output: ExtensionState }; 'bridge.managed-instance.bind': { - input: NonNullable; + input: NonNullable & Pick & { + startupProxy?: string; + }; output: BridgeStatus; }; 'bridge.pair': { input: undefined; output: BridgePairingStatus }; diff --git a/src/types/models.ts b/src/types/models.ts index 5d96b49..94f8de7 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -113,6 +113,13 @@ export interface ProxyRulePage { rules: NormalizedProxyRule[]; } +export interface ProxyStatus { + followingStartup?: boolean; + control: string; + label: string; + activeProfileId?: string; +} + export interface ProxyRuntimeState { dirty: boolean; compiledBytes: number; @@ -196,6 +203,8 @@ export interface BridgeConfig { endpoint: string; autoConnect: boolean; installationId: string; + browserName?: string; + browserVersion?: string; managedInstance?: { manager: 'ytray' | 'yakit'; instanceId: string; @@ -1488,6 +1497,7 @@ export interface DiagnosticsBundle { } export interface ExtensionState { + startupProxy?: string; version: 7; proxyProfiles: ProxyProfile[]; proxyRules: ProxyRule[];