feat(browser): expose proxy state and browser identity (#9)

* feat(proxy): reflect and release browser proxy control

* feat(browser): report browser product identity
This commit is contained in:
go0p
2026-09-16 15:51:33 +08:00
committed by GitHub
parent 2000d59054
commit 4b0df8c706
22 changed files with 441 additions and 32 deletions
+102
View File
@@ -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 });
}
+4
View File
@@ -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) => {
+10 -1
View File
@@ -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',
+2 -1
View File
@@ -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<void>, success?:
}
function Overview({ state, bridge, tab, navigate, run, busy }: { state: ExtensionState; bridge: BridgeStatus; tab?: ActiveTabInfo; navigate: (value: Section) => void; run: (task: () => Promise<void>, success?: string) => Promise<void>; 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<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const [network, setNetwork] = useState<NetworkCaptureStatus>();
const [loginContext, setLoginContext] = useState<PageContext>();
+3 -1
View File
@@ -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; }
@@ -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 <section className="popup-overview-view">
@@ -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<void>, success?: string) => Promise<void>;
@@ -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<ProxyRulePreview>();
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 <section className="popup-view popup-tool-view popup-proxy-view">
<ProxyStatusBar status={status} />
{currentHostname ? <section className="popup-site-router" aria-label="当前站点路由">
<div className="popup-site-router__heading">
<div><Globe2 size={16} /><span><small></small><strong title={currentHostname}>{currentHostname}</strong></span></div>
<i className={routeKind}>{routeKindText}</i>
</div>
<div className="popup-site-decision" title={autoActive ? preview?.matchedCondition : activeModeName}>
<span>{routeLabel}</span><i></i><strong>{routeProfile?.name || '—'}</strong>
<span>{routeLabel}</span><i></i><strong>{routeProfile?.name || status.label}</strong>
</div>
<div className="popup-site-picker">
<label htmlFor="popup-site-proxy"> <span></span></label>
@@ -168,13 +171,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
<div className="popup-mode-heading"><span><strong></strong><small></small></span><i>{activeModeName}</i></div>
<div className="popup-proxy-list popup-proxy-list--view" role="radiogroup" aria-label="浏览器代理模式">
<StartupProxyOption state={state} status={status} setState={setState} run={run} busy={busy} />
<button role="radio" aria-checked={autoActive} className={autoActive ? 'is-active' : ''} disabled={busy} onClick={() => void switchAuto()}>
<span className="popup-mode-icon"><Route size={15} /></span>
<span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {sourceRuleCount.toLocaleString()} </small></span>
{state.proxyRuntime.dirty ? <em></em> : autoActive ? <Check size={14} /> : null}
</button>
{state.proxyProfiles.map((profile) => {
const active = state.activeProxyId === profile.id;
const active = status.activeProfileId === profile.id;
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })), `${profile.name} 已作为全局模式启用`)}>
<span className="popup-mode-icon"><Network size={15} /></span>
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
+3
View File
@@ -25,6 +25,9 @@ async function bootstrap(): Promise<void> {
await request('bridge.managed-instance.bind', {
manager: manager as 'ytray' | 'yakit', instanceId, badge,
browserName: query.get('browserName') || undefined,
browserVersion: query.get('browserVersion') || undefined,
startupProxy: query.get('startupProxy') || undefined,
});
const current = await browser.tabs.getCurrent();
@@ -145,6 +145,7 @@ vi.stubGlobal('WebSocket', FakeWebSocket);
import {
BRIDGE_HEARTBEAT_TIMEOUT_MS,
EngineBridge,
browserClientIdentity,
} from './service';
import { beginAgentAction } from '@/features/agent-runtime/service';
import {
@@ -162,6 +163,8 @@ function bridgeConfig(paired = true) {
endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false,
installationId: 'installation-1',
browserName: 'Chrome for Testing',
browserVersion: '152.0.7977.82',
pairedEngine: paired ? {
engineIdentityId: 'engine-identity-1',
deviceId: 'device-1',
@@ -346,6 +349,8 @@ describe('Engine Bridge transport lifecycle', () => {
expect(auth).toMatchObject({
type: 'auth',
client: 'Chrome for Testing',
version: '152.0.7977.82',
challenge: 'engine-challenge-0123456789',
resumeSessionId: 'previous-session',
});
@@ -494,3 +499,21 @@ describe('Engine Bridge transport lifecycle', () => {
});
});
});
describe('browser client identity', () => {
it('distinguishes Edge and lets managed Chrome for Testing metadata win', () => {
const config = { ...bridgeConfig(false), browserName: undefined, browserVersion: undefined };
expect(browserClientIdentity(
config,
'Mozilla/5.0 AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36 Edg/152.0.1234.5',
)).toEqual({ client: 'Microsoft Edge', version: '152.0.1234.5' });
expect(browserClientIdentity({
...config,
browserName: 'Chrome for Testing',
browserVersion: '152.0.7977.82',
}, 'Mozilla/5.0 Chrome/152.0.0.0')).toEqual({
client: 'Chrome for Testing',
version: '152.0.7977.82',
});
});
});
+22 -3
View File
@@ -33,6 +33,23 @@ const MAX_CONCURRENT_REQUESTS = 8;
const ENGINE_REQUEST_TIMEOUT = 10_000;
const MAX_OUTGOING_REQUESTS = 4;
export function browserClientIdentity(
config: BridgeConfig,
userAgent = globalThis.navigator?.userAgent || '',
): { client: string; version: string } {
const detected = [
[/\bEdg(?:A|iOS)?\/([\d.]+)/, 'Microsoft Edge'],
[/\b(?:Chrome|CriOS)\/([\d.]+)/, 'Google Chrome'],
[/\bChromium\/([\d.]+)/, 'Chromium'],
[/\bFirefox\/([\d.]+)/, 'Firefox'],
].map(([pattern, name]) => ({ match: userAgent.match(pattern as RegExp), name: name as string }))
.find(({ match }) => match);
return {
client: config.browserName?.trim() || detected?.name || 'Browser',
version: config.browserVersion?.trim() || detected?.match?.[1] || '',
};
}
interface OutgoingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
@@ -297,10 +314,11 @@ export class EngineBridge {
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const capabilityCatalog = await getBridgeCapabilityCatalog();
const browserIdentity = browserClientIdentity(config);
const auth: BridgeEnvelope = {
type: 'auth',
client: 'yakit-browser-extension',
version: browser.runtime.getManifest().version,
client: browserIdentity.client,
version: browserIdentity.version,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: [...BRIDGE_CAPABILITIES],
capabilityCatalog,
@@ -781,6 +799,7 @@ export class EngineBridge {
if (this.pairingSocket && ['requesting', 'pending'].includes(currentPairing.state)) return currentPairing;
this.cancelPairing(false);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const browserIdentity = browserClientIdentity(config);
const clientNonce = randomBridgeNonce();
const pairingURL = new URL(config.endpoint);
pairingURL.pathname = '/pairing';
@@ -805,7 +824,7 @@ export class EngineBridge {
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
managedInstance: config.managedInstance,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
client: browserIdentity.client, version: browserIdentity.version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
} catch (error) {
@@ -11,6 +11,7 @@ import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/stor
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
import { isFloatingPanelShortcut, mergeFloatingTabUpdate } from './host-controller';
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
interface FloatingPanelProps {
initialState: ExtensionState;
@@ -21,6 +22,7 @@ interface FloatingPanelProps {
export function FloatingPanel({ initialState, initialTab, initialBridge, hostChannel }: FloatingPanelProps) {
const [state, setState] = useState(initialState);
const proxyStatus = useProxyStatus(state);
const [bridge, setBridge] = useState(initialBridge);
const [tab, setTab] = useState(initialTab);
const [busy, setBusy] = useState(false);
@@ -142,15 +144,17 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
</TabsList>
<TabsContent value="proxy" className="floating-tab-content">
<ProxyStatusBar status={proxyStatus} />
<div className="floating-section-heading"><span></span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
<div className="floating-option-list">
<StartupProxyOption state={state} status={proxyStatus} setState={setState} run={run} busy={busy} />
{state.proxyProfiles.map((profile) => (
<button key={profile.id} className={state.activeProxyId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<button key={profile.id} className={proxyStatus.activeProfileId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="floating-radio" />
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
</button>
))}
<button className={state.activeProxyId === 'auto' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.auto.apply')))}><i className="floating-radio" /><span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {state.proxyRuleSources.filter((source) => source.enabled).length} </small></span></button>
<button className={proxyStatus.activeProfileId === 'auto' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.auto.apply')))}><i className="floating-radio" /><span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {state.proxyRuleSources.filter((source) => source.enabled).length} </small></span></button>
</div>
</TabsContent>
+33 -2
View File
@@ -7,6 +7,7 @@ const harness = vi.hoisted(() => ({
state: undefined as ExtensionState | undefined,
sourceRules: new Map<string, NormalizedProxyRule[]>(),
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` },
+81 -5
View File
@@ -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<string, string>();
const sourceRefreshes = new Map<string, { identity: string; promise: Promise<ExtensionState> }>();
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<void> {
throw new Error('浏览器代理正由其他扩展控制,请先停用其他代理扩展后重试');
}
if (current.levelOfControl === 'not_controllable') {
throw new Error('浏览器代理受系统策略或启动参数控制,当前扩展无法修改');
throw new Error('浏览器报告代理不可由扩展控制,请检查强制管理策略;普通启动代理参数不代表锁定');
}
}
@@ -130,18 +134,89 @@ async function setPacScript(pacScript: string): Promise<void> {
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<void> {
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<void> {
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<ProxyStatus> {
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<string, any>;
const e = expected as Record<string, any>;
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<ExtensionState> {
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<ProxyCompilationInput> {
@@ -213,17 +288,17 @@ export async function saveProxyProfile(profile: ProxyProfile): Promise<Extension
...current,
proxyProfiles: [...current.proxyProfiles.filter((item) => 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<ExtensionState> {
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<ExtensionSt
return dirtyProxyState({
...current,
proxyProfiles: current.proxyProfiles.filter((item) => item.id !== profileId),
activeProxyId: current.activeProxyId === profileId ? '' : current.activeProxyId,
});
});
await setProxyAuthPassword(profileId, '');
+3 -1
View File
@@ -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<ProxyRule>(() => 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();
+7 -3
View File
@@ -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<ProxyProfile>(() => 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
<Button variant="primary" onClick={() => setDraft(createProfile())}><Plus size={16} /></Button>
</div>
<ProxyStatusBar status={status} />
<div className="proxy-profile-workspace">
<section className="proxy-profile-index" aria-label="代理出口列表">
<div className="proxy-panel-label"><span></span><strong>{state.proxyProfiles.length}</strong></div>
<div className="proxy-profile-list">
<StartupProxyOption state={state} status={status} setState={setState} run={run} busy={busy} />
{state.proxyProfiles.map((profile) => <button
key={profile.id}
className={`${draft.id === profile.id ? 'is-selected' : ''} ${state.activeProxyId === profile.id ? 'is-active' : ''}`}
className={`${draft.id === profile.id ? 'is-selected' : ''} ${status.activeProfileId === profile.id ? 'is-active' : ''}`}
onClick={() => setDraft({ ...profile, bypass: [...profile.bypass] })}
>
<span className="proxy-profile-icon"><Network size={16} /></span>
<span><strong>{profile.name}</strong><small>{proxyProfileDetail(profile)}</small></span>
{state.activeProxyId === profile.id && <i>使</i>}
{status.activeProfileId === profile.id && <i>使</i>}
<ChevronRight size={15} />
</button>)}
</div>
@@ -85,7 +89,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
<section className="proxy-profile-editor">
<div className="proxy-editor-heading">
<div><span>{draft.builtin ? '内置出口' : '自定义出口'}</span><h2>{draft.name}</h2></div>
<span className={`proxy-live-state ${state.activeProxyId === draft.id ? 'is-live' : ''}`}><i />{state.activeProxyId === draft.id ? '当前生效' : '未使用'}</span>
<span className={`proxy-live-state ${status.activeProfileId === draft.id ? 'is-live' : ''}`}><i />{status.activeProfileId === draft.id ? '当前生效' : '未确认生效'}</span>
</div>
<div className="proxy-form-grid">
<Field label="名称"><input value={draft.name} disabled={draft.id === 'direct' || draft.id === 'system'} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
+60
View File
@@ -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<ProxyStatus>({ 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 <section className="proxy-effective-status" aria-label="实际代理状态" role="status" title={source}>
<small></small><strong title={`${status.label} · ${source}`}>{status.label}</strong>
</section>;
}
export function StartupProxyOption({ state, status, setState, run, busy }: Pick<ProxyViewProps, 'state' | 'setState' | 'run' | 'busy'> & { 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 <div className={`startup-proxy-option ${active ? 'is-active' : ''}`}>
<button role="radio" aria-checked={active} disabled={busy || status.control === 'loading' || status.control === 'unavailable'} onClick={() => void run(async () => {
setState(await request('proxy.release'));
const actual = await request('proxy.status');
if (!actual.followingStartup) throw new Error(`未能切换到启动配置;实际代理:${actual.label}。请检查其他扩展或管理策略。`);
}, '已跟随启动配置')}>
<span className="startup-proxy-icon"><Monitor size={16} /></span><span className="startup-proxy-label"><strong></strong><small title={detail}>{detail}</small></span><span className="startup-proxy-check">{active && <Check size={14} />}</span>
</button>
<TooltipProvider><Tooltip label="切换后使用浏览器启动时的网络配置;未指定启动代理时,跟随浏览器默认设置。可随时切换到其他模式,已保存的出口和规则不变。若受其他扩展或管理策略影响,以顶部实际代理为准。" side="top">
<button className="startup-proxy-info" aria-label="解释跟随启动配置"><Info size={14} /></button>
</Tooltip></TooltipProvider>
</div>;
}
+22
View File
@@ -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; }
+11 -3
View File
@@ -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<BridgeConfig['managedInstance']>;
}
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>): 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>): 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<ExtensionState> {
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,
+10
View File
@@ -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:[email protected]: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' },
+13 -1
View File
@@ -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,
+5 -1
View File
@@ -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<BridgeConfig['managedInstance']>;
input: NonNullable<BridgeConfig['managedInstance']> & Pick<BridgeConfig, 'browserName' | 'browserVersion'> & {
startupProxy?: string;
};
output: BridgeStatus;
};
'bridge.pair': { input: undefined; output: BridgePairingStatus };
+10
View File
@@ -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[];