mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f53604e8 | ||
|
|
4b0df8c706 | ||
|
|
2000d59054 |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "yakit-chrome-client",
|
"name": "yakit-chrome-client",
|
||||||
"description": "Yakit Browser Extension",
|
"description": "Yakit Browser Extension",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
saveProxyRuleSource,
|
saveProxyRuleSource,
|
||||||
setProxyAuthPassword,
|
setProxyAuthPassword,
|
||||||
switchProxy,
|
switchProxy,
|
||||||
|
getProxyStatus,
|
||||||
|
releaseProxy,
|
||||||
} from '@/features/proxy/service';
|
} from '@/features/proxy/service';
|
||||||
import { updateState } from '@/platform/storage/state';
|
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.save': return ok(await saveProxyProfile(request.payload));
|
||||||
case 'proxy.delete': return ok(await removeProxyProfile(request.payload.id));
|
case 'proxy.delete': return ok(await removeProxyProfile(request.payload.id));
|
||||||
case 'proxy.switch': return ok(await switchProxy(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': {
|
case 'proxy.rule.save': {
|
||||||
const rule = request.payload;
|
const rule = request.payload;
|
||||||
return ok(await updateState((state) => {
|
return ok(await updateState((state) => {
|
||||||
|
|||||||
@@ -428,7 +428,15 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
|||||||
}
|
}
|
||||||
const state = await updateState((current) => ({
|
const state = await updateState((current) => ({
|
||||||
...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);
|
await syncManagedInstanceBadge(state.bridge.managedInstance);
|
||||||
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
|
||||||
@@ -512,6 +520,7 @@ export function runBackground(): void {
|
|||||||
) => {
|
) => {
|
||||||
if ([
|
if ([
|
||||||
'bridge.status.changed',
|
'bridge.status.changed',
|
||||||
|
'proxy.status.changed',
|
||||||
'bridge.pairing.status.changed',
|
'bridge.pairing.status.changed',
|
||||||
'network.capture.changed',
|
'network.capture.changed',
|
||||||
'deep.capture.changed',
|
'deep.capture.changed',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation';
|
import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation';
|
||||||
import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView';
|
import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView';
|
||||||
import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
|
import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
|
||||||
|
import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
|
||||||
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
|
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
|
||||||
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
|
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
|
||||||
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace';
|
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 }) {
|
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 [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
|
||||||
const [network, setNetwork] = useState<NetworkCaptureStatus>();
|
const [network, setNetwork] = useState<NetworkCaptureStatus>();
|
||||||
const [loginContext, setLoginContext] = useState<PageContext>();
|
const [loginContext, setLoginContext] = useState<PageContext>();
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type RecordingTraceContext,
|
type RecordingTraceContext,
|
||||||
type RecordingTraceRuntime,
|
type RecordingTraceRuntime,
|
||||||
} from '@/features/browser-recording/main-world/trace';
|
} from '@/features/browser-recording/main-world/trace';
|
||||||
|
import { recordingExpiryDelay } from '@/features/browser-recording/expiry';
|
||||||
import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget';
|
import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget';
|
||||||
import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size';
|
import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size';
|
||||||
import { ExtensionError } from '@/shared/errors';
|
import { ExtensionError } from '@/shared/errors';
|
||||||
@@ -673,7 +674,14 @@ export default defineUnlistedScript(() => {
|
|||||||
if (active || !startedAt) return;
|
if (active || !startedAt) return;
|
||||||
active = true;
|
active = true;
|
||||||
installObservers();
|
installObservers();
|
||||||
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
|
scheduleExpiry();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleExpiry(): void {
|
||||||
|
const delay = recordingExpiryDelay(options.expiresAt);
|
||||||
|
if (delay === undefined) return;
|
||||||
|
if (delay === 0) { stop(); return; }
|
||||||
|
expiryTimer = window.setTimeout(stop, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(limit = options.maxEntries): RecorderSnapshot {
|
function snapshot(limit = options.maxEntries): RecorderSnapshot {
|
||||||
@@ -863,7 +871,7 @@ export default defineUnlistedScript(() => {
|
|||||||
reseedFingerprints();
|
reseedFingerprints();
|
||||||
active = true;
|
active = true;
|
||||||
installObservers();
|
installObservers();
|
||||||
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
|
scheduleExpiry();
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
if (command === 'resume') {
|
if (command === 'resume') {
|
||||||
|
|||||||
@@ -206,7 +206,9 @@
|
|||||||
.popup-footer { margin-top: auto; padding: 10px 14px 12px; border-top: 1px solid var(--border); background: var(--surface); }
|
.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-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-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); }
|
.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; }
|
.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 { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { request } from '@/platform/messaging/runtime';
|
import { request } from '@/platform/messaging/runtime';
|
||||||
|
import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
|
||||||
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||||
import type { ActiveTabInfo, ExtensionState, UserAgentResolution } from '@/types/models';
|
import type { ActiveTabInfo, ExtensionState, UserAgentResolution } from '@/types/models';
|
||||||
|
|
||||||
@@ -25,9 +26,7 @@ interface OverviewQuickViewProps {
|
|||||||
export function OverviewQuickView({
|
export function OverviewQuickView({
|
||||||
state, tab, grantActive, busy, run, setState, cookieCount, uaResolution, onNavigate, onOpenContext, onCapture,
|
state, tab, grantActive, busy, run, setState, cookieCount, uaResolution, onNavigate, onOpenContext, onCapture,
|
||||||
}: OverviewQuickViewProps) {
|
}: OverviewQuickViewProps) {
|
||||||
const activeProxy = state.activeProxyId === 'auto'
|
const activeProxy = useProxyStatus(state).label;
|
||||||
? '自动切换'
|
|
||||||
: state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || '未选择';
|
|
||||||
const targetAvailable = Boolean(tab?.url?.startsWith('http'));
|
const targetAvailable = Boolean(tab?.url?.startsWith('http'));
|
||||||
|
|
||||||
return <section className="popup-overview-view">
|
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 { AlertCircle, Check, ExternalLink, Globe2, LoaderCircle, Network, Route } from 'lucide-react';
|
||||||
import { request } from '@/platform/messaging/runtime';
|
import { request } from '@/platform/messaging/runtime';
|
||||||
import type { ActiveTabInfo, ExtensionState, ProxyProfile, ProxyRulePreview } from '@/types/models';
|
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>;
|
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) {
|
export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: ProxyQuickViewProps) {
|
||||||
|
const status = useProxyStatus(state);
|
||||||
const [preview, setPreview] = useState<ProxyRulePreview>();
|
const [preview, setPreview] = useState<ProxyRulePreview>();
|
||||||
const currentHostname = hostname(tab?.url);
|
const currentHostname = hostname(tab?.url);
|
||||||
const autoActive = state.activeProxyId === 'auto';
|
const autoActive = status.activeProfileId === 'auto';
|
||||||
const activeProfile = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId);
|
const activeProfile = state.proxyProfiles.find((profile) => profile.id === status.activeProfileId);
|
||||||
const routableProfiles = useMemo(
|
const routableProfiles = useMemo(
|
||||||
() => state.proxyProfiles.filter((profile) => profile.kind === 'direct' || profile.kind === 'fixed_servers'),
|
() => state.proxyProfiles.filter((profile) => profile.kind === 'direct' || profile.kind === 'fixed_servers'),
|
||||||
[state.proxyProfiles],
|
[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 effectiveProfile = state.proxyProfiles.find((profile) => profile.id === preview?.effectiveProfileId);
|
||||||
const activeModeName = autoActive ? '自动切换' : activeProfile?.name || '未选择';
|
const activeModeName = autoActive ? '自动切换' : status.label;
|
||||||
const siteHint = !autoActive
|
const siteHint = !autoActive
|
||||||
? `当前使用“${activeModeName}”;选择网站出口后将启用自动切换。`
|
? `当前使用“${activeModeName}”;选择网站出口后将启用自动切换。`
|
||||||
: siteTarget === AUTOMATIC_TARGET
|
: siteTarget === AUTOMATIC_TARGET
|
||||||
@@ -142,13 +144,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
|
|||||||
const routeKindText = autoActive ? routeKindLabel(preview) : '全局模式';
|
const routeKindText = autoActive ? routeKindLabel(preview) : '全局模式';
|
||||||
|
|
||||||
return <section className="popup-view popup-tool-view popup-proxy-view">
|
return <section className="popup-view popup-tool-view popup-proxy-view">
|
||||||
|
<ProxyStatusBar status={status} />
|
||||||
{currentHostname ? <section className="popup-site-router" aria-label="当前站点路由">
|
{currentHostname ? <section className="popup-site-router" aria-label="当前站点路由">
|
||||||
<div className="popup-site-router__heading">
|
<div className="popup-site-router__heading">
|
||||||
<div><Globe2 size={16} /><span><small>当前站点</small><strong title={currentHostname}>{currentHostname}</strong></span></div>
|
<div><Globe2 size={16} /><span><small>当前站点</small><strong title={currentHostname}>{currentHostname}</strong></span></div>
|
||||||
<i className={routeKind}>{routeKindText}</i>
|
<i className={routeKind}>{routeKindText}</i>
|
||||||
</div>
|
</div>
|
||||||
<div className="popup-site-decision" title={autoActive ? preview?.matchedCondition : activeModeName}>
|
<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>
|
||||||
<div className="popup-site-picker">
|
<div className="popup-site-picker">
|
||||||
<label htmlFor="popup-site-proxy">网站出口 <span>选择后立即生效</span></label>
|
<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-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="浏览器代理模式">
|
<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()}>
|
<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 className="popup-mode-icon"><Route size={15} /></span>
|
||||||
<span><strong>自动切换</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条手动 · {sourceRuleCount.toLocaleString()} 条订阅</small></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}
|
{state.proxyRuntime.dirty ? <em>待应用</em> : autoActive ? <Check size={14} /> : null}
|
||||||
</button>
|
</button>
|
||||||
{state.proxyProfiles.map((profile) => {
|
{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} 已作为全局模式启用`)}>
|
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 className="popup-mode-icon"><Network size={15} /></span>
|
||||||
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
|
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
await request('bridge.managed-instance.bind', {
|
await request('bridge.managed-instance.bind', {
|
||||||
manager: manager as 'ytray' | 'yakit', instanceId, badge,
|
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();
|
const current = await browser.tabs.getCurrent();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import {
|
import {
|
||||||
|
createRecordedPageCallable,
|
||||||
getBrowserRecording,
|
getBrowserRecording,
|
||||||
} from '@/features/browser-recording/service';
|
} from '@/features/browser-recording/service';
|
||||||
import { recordingSnapshotForScope } from '@/features/browser-recording/redaction';
|
import { recordingSnapshotForScope } from '@/features/browser-recording/redaction';
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
type BrowserTransformProfileProposalResult,
|
type BrowserTransformProfileProposalResult,
|
||||||
type BrowserTransformProfileValidationResult,
|
type BrowserTransformProfileValidationResult,
|
||||||
type BrowserTransformValidationDraft,
|
type BrowserTransformValidationDraft,
|
||||||
|
type BrowserTransformDirectionName,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
|
|
||||||
const MAX_TRACE_EVENTS = 80;
|
const MAX_TRACE_EVENTS = 80;
|
||||||
@@ -478,6 +480,37 @@ export async function latestBrowserTransformValidation(
|
|||||||
return draft || memoryValidationDrafts.get(key) || null;
|
return draft || memoryValidationDrafts.get(key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function browserTransformValidationById(
|
||||||
|
validationId: string,
|
||||||
|
): Promise<BrowserTransformValidationDraft> {
|
||||||
|
const now = Date.now();
|
||||||
|
const stored = await readStoredValidationDrafts();
|
||||||
|
const drafts = pruneValidationDrafts(stored, now);
|
||||||
|
if (Object.keys(drafts).length !== Object.keys(stored).length) {
|
||||||
|
validationDraftStorageQueue = validationDraftStorageQueue.then(() => writeStoredValidationDrafts(drafts));
|
||||||
|
await validationDraftStorageQueue;
|
||||||
|
}
|
||||||
|
const draft = Object.values(drafts).find((item) => item.id === validationId)
|
||||||
|
|| [...memoryValidationDrafts.values()].find((item) => item.id === validationId && item.expiresAt > now);
|
||||||
|
if (!draft) {
|
||||||
|
throw new ExtensionError('validation_draft_stale', '验证草稿不存在或已经过期,请重新生成并验证');
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBrowserTransformValidation(
|
||||||
|
validationId: string,
|
||||||
|
direction: BrowserTransformDirectionName,
|
||||||
|
packet: BrowserTransformPacket,
|
||||||
|
): Promise<BrowserTransformExecution> {
|
||||||
|
const draft = await browserTransformValidationById(validationId);
|
||||||
|
const { profile, execution } = await validateBrowserTransformProfile(draft.profile, packet, {
|
||||||
|
direction,
|
||||||
|
profileId: `transient-${validationId}`,
|
||||||
|
});
|
||||||
|
return { ...execution, explanation: profile.explanation, proofLevel: draft.proofLevel };
|
||||||
|
}
|
||||||
|
|
||||||
export async function discardBrowserTransformValidation(
|
export async function discardBrowserTransformValidation(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
validationId: string,
|
validationId: string,
|
||||||
@@ -801,7 +834,7 @@ export function comparePacketWithInferenceCandidate(
|
|||||||
check(
|
check(
|
||||||
checks,
|
checks,
|
||||||
'body-shape',
|
'body-shape',
|
||||||
'已关联的线上字段存在且没有二次 JSON 包装',
|
'线上字段结构一致(不验证加密前的输入内容)',
|
||||||
bodyFieldsPresent,
|
bodyFieldsPresent,
|
||||||
actualShape.signature,
|
actualShape.signature,
|
||||||
bodyFields,
|
bodyFields,
|
||||||
@@ -1045,6 +1078,7 @@ export async function proposeBrowserTransformProfile(
|
|||||||
callableId: string,
|
callableId: string,
|
||||||
inputPaths?: string[],
|
inputPaths?: string[],
|
||||||
name?: string,
|
name?: string,
|
||||||
|
packet?: BrowserTransformPacket,
|
||||||
): Promise<BrowserTransformProfileProposalResult> {
|
): Promise<BrowserTransformProfileProposalResult> {
|
||||||
const [snapshot, callables, tab, frame] = await Promise.all([
|
const [snapshot, callables, tab, frame] = await Promise.all([
|
||||||
getBrowserRecording(target, 500, false),
|
getBrowserRecording(target, 500, false),
|
||||||
@@ -1082,7 +1116,7 @@ export async function proposeBrowserTransformProfile(
|
|||||||
lastAccessed: tab.lastAccessed,
|
lastAccessed: tab.lastAccessed,
|
||||||
};
|
};
|
||||||
const requestEvent = evidence.requestEvent;
|
const requestEvent = evidence.requestEvent;
|
||||||
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate);
|
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate, inputPaths ? undefined : packet);
|
||||||
profile = {
|
profile = {
|
||||||
...profile,
|
...profile,
|
||||||
name: name || profile.name,
|
name: name || profile.name,
|
||||||
@@ -1129,7 +1163,7 @@ export async function proposeBrowserTransformProfile(
|
|||||||
? callable.transaction ? 'captured-request-transaction' : 'validated-callable-envelope'
|
? callable.transaction ? 'captured-request-transaction' : 'validated-callable-envelope'
|
||||||
: 'recording-evidence',
|
: 'recording-evidence',
|
||||||
},
|
},
|
||||||
next: '调用 profile.validate;验证成功后由用户在插件中确认保存,AI 不直接持久化配置',
|
next: '调用 profile.validate;验证成功后可立即用 validationDraft.id 做一次临时明文 HTTP 测试,只有复用配置才需要用户在插件中确认保存',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,7 +1230,7 @@ export async function validateBrowserTransformProposal(
|
|||||||
proofLevel,
|
proofLevel,
|
||||||
normalizedProfile,
|
normalizedProfile,
|
||||||
generated,
|
generated,
|
||||||
execution,
|
execution: { ...execution, explanation: normalized.explanation },
|
||||||
comparison,
|
comparison,
|
||||||
validationDraft: validationDraft ? {
|
validationDraft: validationDraft ? {
|
||||||
contractVersion: validationDraft.contractVersion,
|
contractVersion: validationDraft.contractVersion,
|
||||||
@@ -1206,9 +1240,11 @@ export async function validateBrowserTransformProposal(
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
next: comparison
|
next: comparison
|
||||||
? comparison.equivalent
|
? comparison.equivalent
|
||||||
? '确定性验证通过;插件已生成待用户确认的明文网关草稿'
|
? comparison.mode === 'exact'
|
||||||
|
? '样本报文对比通过;尚未发送业务测试请求,使用 validationDraft.id 调用 browser.http.test'
|
||||||
|
: '仅结构校验通过,不证明明文输入、加密语义或业务成功;使用 validationDraft.id 调用 browser.http.test 验证,请勿原样重复 prepare'
|
||||||
: '数据包对比未通过;检查输入映射或重新选择页面函数'
|
: '数据包对比未通过;检查输入映射或重新选择页面函数'
|
||||||
: 'Pipeline 已真实回放并生成待确认草稿;如需更强证明,请提供一份浏览器线上请求进行结构对比',
|
: 'Pipeline 已真实回放;可将 validationDraft.id 直接交给 browser.http.test。如需更强证明,请提供一份浏览器线上请求进行结构对比',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,6 +1264,7 @@ export async function validateInferredBrowserTransformProfile(
|
|||||||
callableId,
|
callableId,
|
||||||
inputPaths,
|
inputPaths,
|
||||||
name,
|
name,
|
||||||
|
packet,
|
||||||
);
|
);
|
||||||
return validateBrowserTransformProposal(
|
return validateBrowserTransformProposal(
|
||||||
proposal.profile,
|
proposal.profile,
|
||||||
@@ -1237,3 +1274,35 @@ export async function validateInferredBrowserTransformProfile(
|
|||||||
candidateId,
|
candidateId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function prepareCapturedBrowserTransformProfile(
|
||||||
|
target: BrowserTarget,
|
||||||
|
candidateId: string,
|
||||||
|
packet: BrowserTransformPacket,
|
||||||
|
inputPaths?: string[],
|
||||||
|
name?: string,
|
||||||
|
): Promise<BrowserTransformProfileValidationResult> {
|
||||||
|
const candidate = await resolveStagedProfileCandidate(target, candidateId);
|
||||||
|
const source = [candidate.source, ...candidate.sources]
|
||||||
|
.find((item) => item.callHandleId);
|
||||||
|
if (!source?.callHandleId) {
|
||||||
|
throw new ExtensionError(
|
||||||
|
'gateway_capture_required',
|
||||||
|
'本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = (await listPageCallables(target))
|
||||||
|
.find((item) => item.provenance.eventId === source.eventId);
|
||||||
|
const callable = existing || await createRecordedPageCallable(target, {
|
||||||
|
callHandleId: source.callHandleId,
|
||||||
|
name: name || candidate.summary.slice(0, 120) || 'Captured page transform',
|
||||||
|
});
|
||||||
|
return validateInferredBrowserTransformProfile(
|
||||||
|
target,
|
||||||
|
candidate.id,
|
||||||
|
callable.id,
|
||||||
|
packet,
|
||||||
|
inputPaths,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const fixture = vi.hoisted(() => ({
|
||||||
|
executeScript: vi.fn(),
|
||||||
|
startRecording: vi.fn(),
|
||||||
|
stopRecording: vi.fn(),
|
||||||
|
startNetwork: vi.fn(),
|
||||||
|
listNetwork: vi.fn(),
|
||||||
|
stopNetwork: vi.fn(),
|
||||||
|
act: vi.fn(),
|
||||||
|
context: vi.fn(),
|
||||||
|
stageEvidence: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('wxt/browser', () => ({
|
||||||
|
browser: { scripting: { executeScript: fixture.executeScript } },
|
||||||
|
}));
|
||||||
|
vi.mock('@/features/browser-recording/service', () => ({
|
||||||
|
startBrowserRecording: fixture.startRecording,
|
||||||
|
stopBrowserRecording: fixture.stopRecording,
|
||||||
|
}));
|
||||||
|
vi.mock('@/features/network-capture/service', () => ({
|
||||||
|
startNetworkCapture: fixture.startNetwork,
|
||||||
|
listNetworkRequests: fixture.listNetwork,
|
||||||
|
stopNetworkCapture: fixture.stopNetwork,
|
||||||
|
}));
|
||||||
|
vi.mock('@/features/page-context/service', () => ({
|
||||||
|
actOnPageNode: fixture.act,
|
||||||
|
capturePageContext: fixture.context,
|
||||||
|
}));
|
||||||
|
vi.mock('@/features/browser-analysis/service', () => ({
|
||||||
|
listRecordingTraces: vi.fn(() => [{ id: 'trace-1', cryptoCount: 1 }]),
|
||||||
|
stageBrowserProfileEvidence: fixture.stageEvidence,
|
||||||
|
}));
|
||||||
|
vi.mock('@/platform/browser/targets', () => ({
|
||||||
|
scriptingTarget: vi.fn((target) => ({ tabId: target.tabId, documentIds: [target.documentId] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('atomic page crypto inspection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fixture.executeScript
|
||||||
|
.mockResolvedValueOnce([{ result: true }])
|
||||||
|
.mockResolvedValueOnce([{ result: [{ type: 'alert', message: 'done', decision: 'auto_dismissed', timestamp: 2 }] }]);
|
||||||
|
fixture.startRecording.mockResolvedValue({});
|
||||||
|
fixture.stopRecording.mockResolvedValue({
|
||||||
|
status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
|
||||||
|
events: [{
|
||||||
|
id: 'event-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
|
||||||
|
kind: 'crypto', operation: 'AES.encrypt',
|
||||||
|
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', mode: 'CBC', padding: 'Pkcs7' },
|
||||||
|
inputs: [], outputs: [], sensitiveCaptured: true,
|
||||||
|
}],
|
||||||
|
traces: [], links: [], callables: [], profileCandidates: [{
|
||||||
|
id: 'candidate-1', direction: 'request', summary: 'login request',
|
||||||
|
confidence: { score: 0.95, level: 'high' },
|
||||||
|
source: { eventId: 'event-1', callHandleId: 'handle-1' },
|
||||||
|
sources: [],
|
||||||
|
request: {
|
||||||
|
method: 'POST', url: 'https://example.test/api', bodyFormat: 'json',
|
||||||
|
mappings: [{ sourceEventId: 'event-1', destination: '$body.username' }],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fixture.startNetwork.mockResolvedValue({});
|
||||||
|
fixture.listNetwork.mockResolvedValue([{
|
||||||
|
id: 'request-1', requestId: 'devtools-1', tabId: 7, frameId: 0,
|
||||||
|
url: 'https://example.test/api', method: 'POST', resourceType: 'xmlhttprequest',
|
||||||
|
startedAt: 1, completedAt: 2, statusCode: 200,
|
||||||
|
requestHeadersCaptured: false, requestBodyCaptured: true,
|
||||||
|
requestBody: { encoding: 'utf8', data: '{"cipher":"abc"}', byteLength: 16, truncated: false },
|
||||||
|
redirects: [],
|
||||||
|
}]);
|
||||||
|
fixture.stopNetwork.mockResolvedValue({});
|
||||||
|
fixture.act.mockResolvedValue({ action: 'click', status: 'dispatched', dispatchedAt: 1, node: { nodeId: 'n1' } });
|
||||||
|
fixture.context.mockResolvedValue({
|
||||||
|
captureId: 'capture-2',
|
||||||
|
target: { tabId: 7, frameId: 0, documentId: 'doc-1' },
|
||||||
|
authentication: { state: 'authenticated' },
|
||||||
|
document: {
|
||||||
|
title: 'Crypto lab', url: 'https://example.test/', readyState: 'complete', forms: [],
|
||||||
|
interactive: [{ nodeId: 'n2', role: 'button', name: 'Next operation', visible: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('captures one click, crypto evidence, request, and modal dialog in one call', async () => {
|
||||||
|
const { inspectPageCryptoOperation } = await import('./inspect');
|
||||||
|
const result = await inspectPageCryptoOperation(
|
||||||
|
{ tabId: 7, frameId: 0, documentId: 'doc-1' },
|
||||||
|
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 250 },
|
||||||
|
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
state: 'observed',
|
||||||
|
dialogs: [{ type: 'alert', message: 'done', decision: 'auto_dismissed' }],
|
||||||
|
dialogHandling: { autoDismissedAlerts: 1, autoAcceptedConfirms: 0, autoSubmittedPrompts: 0, count: 1, navigationInferred: false },
|
||||||
|
postAction: {
|
||||||
|
sameDocument: true,
|
||||||
|
captureId: 'capture-2',
|
||||||
|
document: { interactive: [{ nodeId: 'n2', name: 'Next operation' }] },
|
||||||
|
},
|
||||||
|
recording: { count: 1, events: [{ kind: 'crypto', operation: 'AES.encrypt' }] },
|
||||||
|
network: { count: 1, requests: [{ method: 'POST', statusCode: 200 }] },
|
||||||
|
gatewayPreparation: {
|
||||||
|
state: 'ready', candidateId: 'candidate-1',
|
||||||
|
request: { method: 'POST', destinations: ['$body.username'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixture.act).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.stopRecording).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.stopNetwork).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.stageEvidence).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const delayedRequest = {
|
||||||
|
id: 'request-delayed', requestId: 'devtools-delayed', tabId: 7, frameId: 2,
|
||||||
|
url: 'https://example.test/delayed', method: 'POST', resourceType: 'xmlhttprequest',
|
||||||
|
startedAt: 1, completedAt: 2, statusCode: 200,
|
||||||
|
requestHeadersCaptured: false, requestBodyCaptured: false, redirects: [],
|
||||||
|
};
|
||||||
|
fixture.listNetwork.mockImplementation(async () => (Date.now() - startedAt >= 900 ? [delayedRequest] : []));
|
||||||
|
const { inspectPageCryptoOperation } = await import('./inspect');
|
||||||
|
let settled = false;
|
||||||
|
const pending = inspectPageCryptoOperation(
|
||||||
|
{ tabId: 7, frameId: 2, documentId: 'doc-frame' },
|
||||||
|
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 1_500 },
|
||||||
|
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
|
||||||
|
).finally(() => { settled = true; });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
const result = await pending;
|
||||||
|
expect(result).toMatchObject({ network: { count: 1, requests: [{ id: 'request-delayed' }] } });
|
||||||
|
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles alert, confirm, and prompt without blocking the page', async () => {
|
||||||
|
const originalAlert = globalThis.alert;
|
||||||
|
const originalConfirm = globalThis.confirm;
|
||||||
|
const originalPrompt = globalThis.prompt;
|
||||||
|
const alert = vi.fn();
|
||||||
|
globalThis.alert = alert;
|
||||||
|
const confirm = vi.fn(() => false);
|
||||||
|
const prompt = vi.fn(() => 'typed value');
|
||||||
|
globalThis.confirm = confirm;
|
||||||
|
globalThis.prompt = prompt;
|
||||||
|
try {
|
||||||
|
const { installPageDialogCapture, restorePageDialogCapture } = await import('./inspect');
|
||||||
|
expect(installPageDialogCapture()).toBe(true);
|
||||||
|
globalThis.alert('notice');
|
||||||
|
expect(globalThis.confirm('continue?')).toBe(true);
|
||||||
|
expect(globalThis.prompt('name?')).toBe('');
|
||||||
|
expect(restorePageDialogCapture()).toMatchObject([
|
||||||
|
{ type: 'alert', decision: 'auto_dismissed' },
|
||||||
|
{ type: 'confirm', decision: 'auto_accepted' },
|
||||||
|
{ type: 'prompt', decision: 'auto_submitted' },
|
||||||
|
]);
|
||||||
|
expect(alert).not.toHaveBeenCalled();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
globalThis.alert = originalAlert;
|
||||||
|
globalThis.confirm = originalConfirm;
|
||||||
|
globalThis.prompt = originalPrompt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
startBrowserRecording,
|
||||||
|
stopBrowserRecording,
|
||||||
|
} from '@/features/browser-recording/service';
|
||||||
|
import {
|
||||||
|
listRecordingTraces,
|
||||||
|
stageBrowserProfileEvidence,
|
||||||
|
} from '@/features/browser-analysis/service';
|
||||||
|
import {
|
||||||
|
listNetworkRequests,
|
||||||
|
startNetworkCapture,
|
||||||
|
stopNetworkCapture,
|
||||||
|
} from '@/features/network-capture/service';
|
||||||
|
import { actOnPageNode, capturePageContext } from '@/features/page-context/service';
|
||||||
|
import {
|
||||||
|
beginPageDialogCapture,
|
||||||
|
endPageDialogCapture,
|
||||||
|
installPageDialogCapture,
|
||||||
|
restorePageDialogCapture,
|
||||||
|
} from '@/features/page-context/dialogs';
|
||||||
|
import { ExtensionError } from '@/shared/errors';
|
||||||
|
import type {
|
||||||
|
BrowserRecordingEvent,
|
||||||
|
BrowserRecordingSnapshot,
|
||||||
|
BrowserTarget,
|
||||||
|
NetworkRequestRecord,
|
||||||
|
PageDialog,
|
||||||
|
PageNodeActionResult,
|
||||||
|
} from '@/types/models';
|
||||||
|
|
||||||
|
type InspectionOwner = { grantId: string; expiresAt: number };
|
||||||
|
export { installPageDialogCapture, restorePageDialogCapture };
|
||||||
|
|
||||||
|
function clipped(value: string | undefined, max: number): string | undefined {
|
||||||
|
return value === undefined ? undefined : value.slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactEvent(event: BrowserRecordingEvent): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
traceId: event.traceId,
|
||||||
|
kind: event.kind,
|
||||||
|
operation: event.operation,
|
||||||
|
label: event.label,
|
||||||
|
crypto: event.crypto,
|
||||||
|
transform: event.transform,
|
||||||
|
direction: event.direction,
|
||||||
|
method: event.method,
|
||||||
|
statusCode: event.statusCode,
|
||||||
|
url: clipped(event.url, 2_048),
|
||||||
|
dataType: event.dataType,
|
||||||
|
byteLength: event.byteLength,
|
||||||
|
resultByteLength: event.resultByteLength,
|
||||||
|
scriptUrl: clipped(event.scriptUrl, 2_048),
|
||||||
|
stack: clipped(event.stack, 512),
|
||||||
|
callableCapable: event.callableCapable,
|
||||||
|
callHandleId: event.callHandleId,
|
||||||
|
arguments: event.arguments?.slice(0, 8),
|
||||||
|
inputs: event.inputs.slice(0, 12),
|
||||||
|
outputs: event.outputs.slice(0, 12),
|
||||||
|
inputPreview: clipped(event.inputPreview, 1_024),
|
||||||
|
outputPreview: clipped(event.outputPreview, 1_024),
|
||||||
|
error: event.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactRequest(request: NetworkRequestRecord): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
method: request.method,
|
||||||
|
url: clipped(request.url, 4_096),
|
||||||
|
resourceType: request.resourceType,
|
||||||
|
statusCode: request.statusCode,
|
||||||
|
durationMs: request.durationMs,
|
||||||
|
error: request.error,
|
||||||
|
requestBody: request.requestBody && {
|
||||||
|
...request.requestBody,
|
||||||
|
data: clipped(request.requestBody.data, 2_048),
|
||||||
|
},
|
||||||
|
responseContentType: request.responseContentType,
|
||||||
|
responseSize: request.responseSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCryptoInspection(
|
||||||
|
snapshot: BrowserRecordingSnapshot,
|
||||||
|
requests: NetworkRequestRecord[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const events = snapshot.events.slice(-16);
|
||||||
|
const cryptoCount = events.filter((event) => event.kind === 'crypto').length;
|
||||||
|
const transformCount = events.filter((event) => event.kind === 'transform').length;
|
||||||
|
const state = cryptoCount + transformCount > 0
|
||||||
|
? 'observed'
|
||||||
|
: events.length + requests.length > 0
|
||||||
|
? 'boundary_only'
|
||||||
|
: 'no_evidence';
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
summary: state === 'observed'
|
||||||
|
? `已观测到 ${cryptoCount} 次密码调用和 ${transformCount} 次编码/序列化转换`
|
||||||
|
: state === 'boundary_only'
|
||||||
|
? '已观测到页面或网络边界,但未命中已知加解密适配器'
|
||||||
|
: '本次页面操作没有产生可分析证据',
|
||||||
|
recording: {
|
||||||
|
count: snapshot.status.count,
|
||||||
|
droppedCount: snapshot.status.droppedCount,
|
||||||
|
events: events.map(compactEvent),
|
||||||
|
traces: listRecordingTraces(snapshot, 6),
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
count: requests.length,
|
||||||
|
requests: requests.slice(0, 8).map(compactRequest),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInspectionIdle(target: BrowserTarget, maxWaitMs: number): Promise<NetworkRequestRecord[]> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastChangedAt = startedAt;
|
||||||
|
let previousSignature = '';
|
||||||
|
let observedActivity = false;
|
||||||
|
let requests: NetworkRequestRecord[] = [];
|
||||||
|
while (Date.now() - startedAt < maxWaitMs) {
|
||||||
|
requests = await listNetworkRequests(target, 20);
|
||||||
|
const signature = requests.map((item) => `${item.id}:${item.completedAt || ''}:${item.error || ''}`).join('|');
|
||||||
|
if (signature !== previousSignature) {
|
||||||
|
previousSignature = signature;
|
||||||
|
lastChangedAt = Date.now();
|
||||||
|
}
|
||||||
|
if (requests.length > 0) observedActivity = true;
|
||||||
|
const allFinished = requests.every((item) => item.completedAt !== undefined || Boolean(item.error));
|
||||||
|
if (observedActivity && Date.now() - startedAt >= 500 && allFinished && Date.now() - lastChangedAt >= 350) break;
|
||||||
|
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectPageCryptoOperation(
|
||||||
|
target: BrowserTarget,
|
||||||
|
input: { captureId: string; nodeId: string; settleMs?: number },
|
||||||
|
owner: InspectionOwner,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const settleMs = Math.max(250, Math.min(input.settleMs || 4_000, 5_000));
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let action: PageNodeActionResult | undefined;
|
||||||
|
let snapshot: BrowserRecordingSnapshot | undefined;
|
||||||
|
let requests: NetworkRequestRecord[] = [];
|
||||||
|
let dialogs: PageDialog[] = [];
|
||||||
|
let postAction: Record<string, unknown> | undefined;
|
||||||
|
let recordingStarted = false;
|
||||||
|
let networkStarted = false;
|
||||||
|
let dialogCaptureOwned = false;
|
||||||
|
|
||||||
|
dialogCaptureOwned = await beginPageDialogCapture(target);
|
||||||
|
try {
|
||||||
|
await startNetworkCapture(target, {
|
||||||
|
captureHeaders: false,
|
||||||
|
captureBody: true,
|
||||||
|
maxEntries: 40,
|
||||||
|
maxBodyBytes: 8_192,
|
||||||
|
}, {
|
||||||
|
kind: 'grant',
|
||||||
|
grantId: owner.grantId,
|
||||||
|
expiresAt: owner.expiresAt,
|
||||||
|
followSameOriginNavigation: true,
|
||||||
|
});
|
||||||
|
networkStarted = true;
|
||||||
|
await startBrowserRecording(target, {
|
||||||
|
captureValues: true,
|
||||||
|
maxEntries: 160,
|
||||||
|
maxValueBytes: 4_096,
|
||||||
|
expiresAt: owner.expiresAt,
|
||||||
|
}, { kind: 'grant', grantId: owner.grantId, expiresAt: owner.expiresAt });
|
||||||
|
recordingStarted = true;
|
||||||
|
|
||||||
|
action = await actOnPageNode(input.captureId, input.nodeId, 'click', target);
|
||||||
|
requests = await waitForInspectionIdle(target, settleMs);
|
||||||
|
snapshot = await stopBrowserRecording(target, true);
|
||||||
|
recordingStarted = false;
|
||||||
|
} finally {
|
||||||
|
if (recordingStarted) {
|
||||||
|
try { snapshot = await stopBrowserRecording(target, true); } catch (error) {
|
||||||
|
warnings.push(`停止页面录制失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (networkStarted) {
|
||||||
|
try {
|
||||||
|
if (!requests.length) requests = await listNetworkRequests(target, 20);
|
||||||
|
await stopNetworkCapture(target);
|
||||||
|
} catch (error) {
|
||||||
|
warnings.push(`停止网络观察失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
|
||||||
|
try {
|
||||||
|
const context = await capturePageContext({ includeDom: true }, {
|
||||||
|
tabId: target.tabId,
|
||||||
|
frameId: target.frameId,
|
||||||
|
});
|
||||||
|
postAction = {
|
||||||
|
sameDocument: Boolean(target.documentId && context.target.documentId === target.documentId),
|
||||||
|
captureId: context.captureId,
|
||||||
|
target: context.target,
|
||||||
|
authentication: context.authentication,
|
||||||
|
document: {
|
||||||
|
title: context.document.title,
|
||||||
|
url: context.document.url,
|
||||||
|
readyState: context.document.readyState,
|
||||||
|
forms: context.document.forms,
|
||||||
|
interactive: context.document.interactive,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
warnings.push(`采集操作后页面状态失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot || !action) {
|
||||||
|
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
|
||||||
|
}
|
||||||
|
await stageBrowserProfileEvidence(snapshot);
|
||||||
|
const preparation = snapshot.profileCandidates
|
||||||
|
.filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources]
|
||||||
|
.some((source) => Boolean(source.callHandleId)))
|
||||||
|
.sort((left, right) => right.confidence.score - left.confidence.score)[0];
|
||||||
|
const evidence = summarizeCryptoInspection(snapshot, requests);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
target,
|
||||||
|
trigger: { captureId: input.captureId, nodeId: input.nodeId, action: 'click' },
|
||||||
|
action,
|
||||||
|
startedAt,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
dialogs,
|
||||||
|
dialogHandling: {
|
||||||
|
strategy: 'nonblocking-local-dialog-defaults',
|
||||||
|
autoDismissedAlerts: dialogs.filter((dialog) => dialog.type === 'alert').length,
|
||||||
|
autoAcceptedConfirms: dialogs.filter((dialog) => dialog.type === 'confirm').length,
|
||||||
|
autoSubmittedPrompts: dialogs.filter((dialog) => dialog.type === 'prompt').length,
|
||||||
|
count: dialogs.length,
|
||||||
|
navigationInferred: false,
|
||||||
|
},
|
||||||
|
postAction,
|
||||||
|
gatewayPreparation: preparation ? {
|
||||||
|
state: 'ready',
|
||||||
|
candidateId: preparation.id,
|
||||||
|
direction: preparation.direction,
|
||||||
|
confidence: preparation.confidence,
|
||||||
|
request: {
|
||||||
|
method: preparation.request.method,
|
||||||
|
url: preparation.request.url,
|
||||||
|
bodyFormat: preparation.request.bodyFormat,
|
||||||
|
destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean),
|
||||||
|
},
|
||||||
|
next: '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;不要再调用 recording、callable、debugger 或 profile 底层能力',
|
||||||
|
} : {
|
||||||
|
state: 'unavailable',
|
||||||
|
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
...evidence,
|
||||||
|
purpose: '仅分析这一次页面操作;未创建、验证或保存明文网关 Profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { recordingExpiryDelay } from './expiry';
|
||||||
|
|
||||||
|
describe('recording expiry timer', () => {
|
||||||
|
it('does not overflow a permanent paired-browser grant into an immediate timeout', () => {
|
||||||
|
expect(recordingExpiryDelay(Number.MAX_SAFE_INTEGER, 1_000)).toBeUndefined();
|
||||||
|
expect(recordingExpiryDelay(11_000, 1_000)).toBe(10_000);
|
||||||
|
expect(recordingExpiryDelay(999, 1_000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const MAX_BROWSER_TIMER_MS = 0x7fffffff;
|
||||||
|
|
||||||
|
export function recordingExpiryDelay(expiresAt: number | undefined, now = Date.now()): number | undefined {
|
||||||
|
if (expiresAt === undefined) return undefined;
|
||||||
|
const delay = expiresAt - now;
|
||||||
|
if (delay <= 0) return 0;
|
||||||
|
return delay <= MAX_BROWSER_TIMER_MS ? delay : undefined;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
BrowserProfileInferenceCandidate,
|
BrowserProfileInferenceCandidate,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import { createBrowserTransformProfileInput } from './profile-draft';
|
import { createBrowserTransformProfileInput } from './profile-draft';
|
||||||
|
import { executeTransformDirection } from './mapping';
|
||||||
|
|
||||||
const tab: ActiveTabInfo = {
|
const tab: ActiveTabInfo = {
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -66,6 +67,26 @@ const responseCandidate = {
|
|||||||
} satisfies BrowserProfileInferenceCandidate;
|
} satisfies BrowserProfileInferenceCandidate;
|
||||||
|
|
||||||
describe('browser transform profile draft', () => {
|
describe('browser transform profile draft', () => {
|
||||||
|
it('reads only the captured form field for a single string input and rejects ambiguous fields', async () => {
|
||||||
|
const candidate = { ...responseCandidate, direction: 'request' as const,
|
||||||
|
request: { ...responseCandidate.request, bodyFormat: 'form' as const, serialization: 'form-field' as const } };
|
||||||
|
const packet = { method: 'POST', url: candidate.request.url,
|
||||||
|
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded; charset=utf-8' }],
|
||||||
|
bodyBase64: btoa('encryptedData={"username":"admin","password":"admin123"}') };
|
||||||
|
const profile = createBrowserTransformProfileInput(tab, undefined, callable, candidate, packet);
|
||||||
|
expect(profile.request.nodes.filter((node) => node.kind === 'context.read')).toMatchObject([{ path: 'body.encryptedData' }]);
|
||||||
|
let received: unknown[] = [];
|
||||||
|
await executeTransformDirection('test', 'request', profile.request, packet, async (callableId, args) => {
|
||||||
|
received = args;
|
||||||
|
return { callableId, type: 'string', preview: 'cipher', value: 'cipher', durationMs: 1 };
|
||||||
|
});
|
||||||
|
expect(received).toEqual(['{"username":"admin","password":"admin123"}']);
|
||||||
|
for (const body of ['username=admin', 'encryptedData=a&encryptedData=b']) {
|
||||||
|
expect(() => createBrowserTransformProfileInput(tab, undefined, callable, candidate, { ...packet, bodyBase64: btoa(body) })).toThrow(/input_paths/);
|
||||||
|
}
|
||||||
|
const jsonPacket = { ...packet, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: btoa('{"username":"admin"}') };
|
||||||
|
expect(createBrowserTransformProfileInput(tab, undefined, callable, candidate, jsonPacket).request.nodes[0]).toMatchObject({ path: 'body' });
|
||||||
|
});
|
||||||
it('compiles an inferred response decryptor into the response direction', () => {
|
it('compiles an inferred response decryptor into the response direction', () => {
|
||||||
const profile = createBrowserTransformProfileInput(tab, undefined, callable, responseCandidate);
|
const profile = createBrowserTransformProfileInput(tab, undefined, callable, responseCandidate);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type {
|
|||||||
BrowserRecordingEvent,
|
BrowserRecordingEvent,
|
||||||
BrowserTransformDirection,
|
BrowserTransformDirection,
|
||||||
BrowserTransformProfileInput,
|
BrowserTransformProfileInput,
|
||||||
|
BrowserTransformPacket,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
|
import { ExtensionError } from '@/shared/errors';
|
||||||
import { compileGuidedTransform, defaultGuidedTransform, type GuidedTransformOutputKind } from './guided';
|
import { compileGuidedTransform, defaultGuidedTransform, type GuidedTransformOutputKind } from './guided';
|
||||||
|
|
||||||
interface RequestRouteSource {
|
interface RequestRouteSource {
|
||||||
@@ -26,7 +28,7 @@ function emptyDirection(enabled = false): BrowserTransformDirection {
|
|||||||
return { enabled, nodes: [] };
|
return { enabled, nodes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
|
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): {
|
||||||
inputPaths?: string[];
|
inputPaths?: string[];
|
||||||
outputKind?: GuidedTransformOutputKind;
|
outputKind?: GuidedTransformOutputKind;
|
||||||
outputField?: string;
|
outputField?: string;
|
||||||
@@ -37,7 +39,23 @@ function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
|
|||||||
if (candidate?.direction === 'response') {
|
if (candidate?.direction === 'response') {
|
||||||
return { inputPaths: [destination], outputKind: 'body' };
|
return { inputPaths: [destination], outputKind: 'body' };
|
||||||
}
|
}
|
||||||
if (serialization === 'form-field') return { outputKind: 'form-field', outputField: destination.slice(5) };
|
if (serialization === 'form-field') {
|
||||||
|
let inputPaths: string[] | undefined;
|
||||||
|
const slots = callable?.inputSlots.filter((slot) => !slot.retained);
|
||||||
|
if (packet && slots?.length === 1 && slots[0].dataType === 'string') {
|
||||||
|
const contentType = packet.headers.find((header) => header.name.toLowerCase() === 'content-type')?.value.split(';')[0].trim().toLowerCase();
|
||||||
|
if (contentType === 'application/x-www-form-urlencoded') {
|
||||||
|
const body = new TextDecoder().decode(Uint8Array.from(atob(packet.bodyBase64), (char) => char.charCodeAt(0)));
|
||||||
|
const fields = new URLSearchParams(body);
|
||||||
|
const destinations = new Set(candidate?.request.mappings.map((mapping) => mapping.destination));
|
||||||
|
if (destinations.size > 1 || fields.getAll(destination.slice(5)).length !== 1) {
|
||||||
|
throw new ExtensionError('profile_input_mismatch', '无法唯一确定表单明文输入,请显式指定 input_paths;尚未发送请求');
|
||||||
|
}
|
||||||
|
inputPaths = [destination];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { inputPaths, outputKind: 'form-field', outputField: destination.slice(5) };
|
||||||
|
}
|
||||||
if (serialization === 'json-field') return { outputKind: 'json-field', outputField: destination.slice(5) };
|
if (serialization === 'json-field') return { outputKind: 'json-field', outputField: destination.slice(5) };
|
||||||
if (serialization === 'header') return { outputKind: 'header', outputField: destination.slice(7) };
|
if (serialization === 'header') return { outputKind: 'header', outputField: destination.slice(7) };
|
||||||
if (serialization === 'query') return { outputKind: 'query', outputField: destination.slice(6) };
|
if (serialization === 'query') return { outputKind: 'query', outputField: destination.slice(6) };
|
||||||
@@ -49,8 +67,9 @@ export function createBrowserTransformProfileInput(
|
|||||||
event?: BrowserRecordingEvent,
|
event?: BrowserRecordingEvent,
|
||||||
callable?: BrowserPageCallable,
|
callable?: BrowserPageCallable,
|
||||||
candidate?: BrowserProfileInferenceCandidate,
|
candidate?: BrowserProfileInferenceCandidate,
|
||||||
|
packet?: BrowserTransformPacket,
|
||||||
): BrowserTransformProfileInput {
|
): BrowserTransformProfileInput {
|
||||||
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate));
|
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate, callable, packet));
|
||||||
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
|
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
|
||||||
const responseDirection = candidate?.direction === 'response';
|
const responseDirection = candidate?.direction === 'response';
|
||||||
const routeEvent = candidate ? {
|
const routeEvent = candidate ? {
|
||||||
|
|||||||
@@ -855,9 +855,10 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
|
|||||||
direction,
|
direction,
|
||||||
input.packet,
|
input.packet,
|
||||||
);
|
);
|
||||||
return input.direction === 'request'
|
const result = input.direction === 'request'
|
||||||
? await bindOnlineTransactionSession(profile, execution)
|
? await bindOnlineTransactionSession(profile, execution)
|
||||||
: execution;
|
: execution;
|
||||||
|
return { ...result, explanation: profile.explanation };
|
||||||
} finally {
|
} finally {
|
||||||
leave();
|
leave();
|
||||||
}
|
}
|
||||||
@@ -866,6 +867,7 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
|
|||||||
export async function validateBrowserTransformProfile(
|
export async function validateBrowserTransformProfile(
|
||||||
input: BrowserTransformProfileInput,
|
input: BrowserTransformProfileInput,
|
||||||
packet: BrowserTransformExecuteInput['packet'],
|
packet: BrowserTransformExecuteInput['packet'],
|
||||||
|
options: { direction?: BrowserTransformDirectionName; profileId?: string } = {},
|
||||||
): Promise<{ profile: BrowserTransformProfile; execution: BrowserTransformExecution }> {
|
): Promise<{ profile: BrowserTransformProfile; execution: BrowserTransformExecution }> {
|
||||||
const target = await resolveDocumentTarget(input.target);
|
const target = await resolveDocumentTarget(input.target);
|
||||||
const isolation = await currentTransformIsolation(target);
|
const isolation = await currentTransformIsolation(target);
|
||||||
@@ -878,19 +880,22 @@ export async function validateBrowserTransformProfile(
|
|||||||
const normalized = withRequestTransactionBinding(
|
const normalized = withRequestTransactionBinding(
|
||||||
normalizeProfile({
|
normalizeProfile({
|
||||||
...input,
|
...input,
|
||||||
id: `validation-${crypto.randomUUID()}`,
|
id: options.profileId || `validation-${crypto.randomUUID()}`,
|
||||||
target,
|
target,
|
||||||
maxConcurrency: transactionSafeConcurrency(input, callables),
|
maxConcurrency: transactionSafeConcurrency(input, callables),
|
||||||
}, isolation),
|
}, isolation),
|
||||||
requestTransaction,
|
requestTransaction,
|
||||||
);
|
);
|
||||||
const profile = withTransformExplanation(normalized, callables);
|
const profile = withTransformExplanation(normalized, callables);
|
||||||
const directionName: BrowserTransformDirectionName = profile.request.enabled
|
const directionName: BrowserTransformDirectionName = options.direction || (profile.request.enabled
|
||||||
? 'request'
|
? 'request'
|
||||||
: profile.response.enabled ? 'response' : 'request';
|
: profile.response.enabled ? 'response' : 'request');
|
||||||
const direction = profile[directionName];
|
const direction = profile[directionName];
|
||||||
if (!profile.enabled || !direction.enabled) {
|
if (!profile.enabled) {
|
||||||
throw new ExtensionError('transform_direction_disabled', '候选明文网关没有启用任何转换方向');
|
throw new ExtensionError('transform_profile_disabled', '候选明文网关未启用');
|
||||||
|
}
|
||||||
|
if (!direction.enabled) {
|
||||||
|
throw new ExtensionError('transform_direction_disabled', `候选明文网关未启用 ${directionName} 转换方向`);
|
||||||
}
|
}
|
||||||
assertTransformRoute(profile.match.methods, profile.match.urlPattern, packet, profile.origin);
|
assertTransformRoute(profile.match.methods, profile.match.urlPattern, packet, profile.origin);
|
||||||
assertRequestTransactionPacket(profile, packet);
|
assertRequestTransactionPacket(profile, packet);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ vi.stubGlobal('WebSocket', FakeWebSocket);
|
|||||||
import {
|
import {
|
||||||
BRIDGE_HEARTBEAT_TIMEOUT_MS,
|
BRIDGE_HEARTBEAT_TIMEOUT_MS,
|
||||||
EngineBridge,
|
EngineBridge,
|
||||||
|
browserClientIdentity,
|
||||||
} from './service';
|
} from './service';
|
||||||
import { beginAgentAction } from '@/features/agent-runtime/service';
|
import { beginAgentAction } from '@/features/agent-runtime/service';
|
||||||
import {
|
import {
|
||||||
@@ -162,6 +163,8 @@ function bridgeConfig(paired = true) {
|
|||||||
endpoint: 'ws://127.0.0.1:64333/extension',
|
endpoint: 'ws://127.0.0.1:64333/extension',
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
installationId: 'installation-1',
|
installationId: 'installation-1',
|
||||||
|
browserName: 'Chrome for Testing',
|
||||||
|
browserVersion: '152.0.7977.82',
|
||||||
pairedEngine: paired ? {
|
pairedEngine: paired ? {
|
||||||
engineIdentityId: 'engine-identity-1',
|
engineIdentityId: 'engine-identity-1',
|
||||||
deviceId: 'device-1',
|
deviceId: 'device-1',
|
||||||
@@ -346,6 +349,8 @@ describe('Engine Bridge transport lifecycle', () => {
|
|||||||
|
|
||||||
expect(auth).toMatchObject({
|
expect(auth).toMatchObject({
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
|
client: 'Chrome for Testing',
|
||||||
|
version: '152.0.7977.82',
|
||||||
challenge: 'engine-challenge-0123456789',
|
challenge: 'engine-challenge-0123456789',
|
||||||
resumeSessionId: 'previous-session',
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ const MAX_CONCURRENT_REQUESTS = 8;
|
|||||||
const ENGINE_REQUEST_TIMEOUT = 10_000;
|
const ENGINE_REQUEST_TIMEOUT = 10_000;
|
||||||
const MAX_OUTGOING_REQUESTS = 4;
|
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 {
|
interface OutgoingRequest {
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
@@ -297,10 +314,11 @@ export class EngineBridge {
|
|||||||
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
|
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
|
||||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||||
const capabilityCatalog = await getBridgeCapabilityCatalog();
|
const capabilityCatalog = await getBridgeCapabilityCatalog();
|
||||||
|
const browserIdentity = browserClientIdentity(config);
|
||||||
const auth: BridgeEnvelope = {
|
const auth: BridgeEnvelope = {
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
client: 'yakit-browser-extension',
|
client: browserIdentity.client,
|
||||||
version: browser.runtime.getManifest().version,
|
version: browserIdentity.version,
|
||||||
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||||
capabilities: [...BRIDGE_CAPABILITIES],
|
capabilities: [...BRIDGE_CAPABILITIES],
|
||||||
capabilityCatalog,
|
capabilityCatalog,
|
||||||
@@ -781,6 +799,7 @@ export class EngineBridge {
|
|||||||
if (this.pairingSocket && ['requesting', 'pending'].includes(currentPairing.state)) return currentPairing;
|
if (this.pairingSocket && ['requesting', 'pending'].includes(currentPairing.state)) return currentPairing;
|
||||||
this.cancelPairing(false);
|
this.cancelPairing(false);
|
||||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||||
|
const browserIdentity = browserClientIdentity(config);
|
||||||
const clientNonce = randomBridgeNonce();
|
const clientNonce = randomBridgeNonce();
|
||||||
const pairingURL = new URL(config.endpoint);
|
const pairingURL = new URL(config.endpoint);
|
||||||
pairingURL.pathname = '/pairing';
|
pairingURL.pathname = '/pairing';
|
||||||
@@ -805,7 +824,7 @@ export class EngineBridge {
|
|||||||
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||||
installationId: config.installationId,
|
installationId: config.installationId,
|
||||||
managedInstance: config.managedInstance,
|
managedInstance: config.managedInstance,
|
||||||
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
|
client: browserIdentity.client, version: browserIdentity.version,
|
||||||
nonce: clientNonce, publicKey: identity.publicKey,
|
nonce: clientNonce, publicKey: identity.publicKey,
|
||||||
} satisfies BridgePairingEnvelope));
|
} satisfies BridgePairingEnvelope));
|
||||||
} catch (error) {
|
} 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 type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
|
||||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||||
import { isFloatingPanelShortcut, mergeFloatingTabUpdate } from './host-controller';
|
import { isFloatingPanelShortcut, mergeFloatingTabUpdate } from './host-controller';
|
||||||
|
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
|
||||||
|
|
||||||
interface FloatingPanelProps {
|
interface FloatingPanelProps {
|
||||||
initialState: ExtensionState;
|
initialState: ExtensionState;
|
||||||
@@ -21,6 +22,7 @@ interface FloatingPanelProps {
|
|||||||
|
|
||||||
export function FloatingPanel({ initialState, initialTab, initialBridge, hostChannel }: FloatingPanelProps) {
|
export function FloatingPanel({ initialState, initialTab, initialBridge, hostChannel }: FloatingPanelProps) {
|
||||||
const [state, setState] = useState(initialState);
|
const [state, setState] = useState(initialState);
|
||||||
|
const proxyStatus = useProxyStatus(state);
|
||||||
const [bridge, setBridge] = useState(initialBridge);
|
const [bridge, setBridge] = useState(initialBridge);
|
||||||
const [tab, setTab] = useState(initialTab);
|
const [tab, setTab] = useState(initialTab);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -142,15 +144,17 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="proxy" className="floating-tab-content">
|
<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-section-heading"><span>快速切换</span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
|
||||||
<div className="floating-option-list">
|
<div className="floating-option-list">
|
||||||
|
<StartupProxyOption state={state} status={proxyStatus} setState={setState} run={run} busy={busy} />
|
||||||
{state.proxyProfiles.map((profile) => (
|
{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" />
|
<i className="floating-radio" />
|
||||||
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
|
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export const NETWORK_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
|
|||||||
|
|
||||||
export const RECORDING_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
|
export const RECORDING_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
|
||||||
id: 'recording-callable-debugger',
|
id: 'recording-callable-debugger',
|
||||||
owns: (method) => method.startsWith('browser.recording.')
|
owns: (method) => method === 'browser.crypto.inspect'
|
||||||
|
|| method.startsWith('browser.recording.')
|
||||||
|| method.startsWith('browser.callable.')
|
|| method.startsWith('browser.callable.')
|
||||||
|| method.startsWith('browser.deep_capture.'),
|
|| method.startsWith('browser.deep_capture.'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,10 +33,28 @@ import {
|
|||||||
stageBrowserProfileEvidence,
|
stageBrowserProfileEvidence,
|
||||||
} from '@/features/browser-analysis/service';
|
} from '@/features/browser-analysis/service';
|
||||||
import { RECORDING_CAPABILITY_DOMAIN } from '../capability-domains';
|
import { RECORDING_CAPABILITY_DOMAIN } from '../capability-domains';
|
||||||
|
import { inspectPageCryptoOperation } from '@/features/browser-crypto/inspect';
|
||||||
|
|
||||||
export const recordingCapabilityHandler: CapabilityDomainHandler = {
|
export const recordingCapabilityHandler: CapabilityDomainHandler = {
|
||||||
...RECORDING_CAPABILITY_DOMAIN,
|
...RECORDING_CAPABILITY_DOMAIN,
|
||||||
async handle({ method, input, grant }) {
|
async handle({ method, input, grant }) {
|
||||||
|
if (method === 'browser.crypto.inspect') {
|
||||||
|
for (const scope of [
|
||||||
|
'browser.recording.control',
|
||||||
|
'browser.recording.sensitive.read',
|
||||||
|
'browser.network.capture',
|
||||||
|
'browser.network.sensitive.read',
|
||||||
|
] as const) requireScope(grant, scope);
|
||||||
|
return inspectPageCryptoOperation(
|
||||||
|
await allowedTarget(grant, input),
|
||||||
|
{
|
||||||
|
captureId: String(input.captureId || ''),
|
||||||
|
nodeId: String(input.nodeId || ''),
|
||||||
|
settleMs: typeof input.settleMs === 'number' ? input.settleMs : undefined,
|
||||||
|
},
|
||||||
|
{ grantId: grant.id, expiresAt: grant.expiresAt },
|
||||||
|
);
|
||||||
|
}
|
||||||
if (method.startsWith('browser.recording.')) {
|
if (method.startsWith('browser.recording.')) {
|
||||||
const target = await allowedTarget(grant, input);
|
const target = await allowedTarget(grant, input);
|
||||||
if (method === 'browser.recording.trace.list') {
|
if (method === 'browser.recording.trace.list') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BrowserTransformExecuteInput,
|
BrowserTransformExecuteInput,
|
||||||
BrowserTransformPacket,
|
BrowserTransformPacket,
|
||||||
|
BrowserTransformValidationExecuteInput,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import type { CapabilityDomainHandler } from '../capability-context';
|
import type { CapabilityDomainHandler } from '../capability-context';
|
||||||
import { allowedTarget, requireScope } from '../capability-context';
|
import { allowedTarget, requireScope } from '../capability-context';
|
||||||
@@ -18,7 +19,10 @@ import {
|
|||||||
} from '@/features/browser-transform/service';
|
} from '@/features/browser-transform/service';
|
||||||
import {
|
import {
|
||||||
compareBrowserPackets,
|
compareBrowserPackets,
|
||||||
|
browserTransformValidationById,
|
||||||
|
executeBrowserTransformValidation,
|
||||||
latestBrowserTransformValidation,
|
latestBrowserTransformValidation,
|
||||||
|
prepareCapturedBrowserTransformProfile,
|
||||||
proposeBrowserTransformProfile,
|
proposeBrowserTransformProfile,
|
||||||
validateInferredBrowserTransformProfile,
|
validateInferredBrowserTransformProfile,
|
||||||
} from '@/features/browser-analysis/service';
|
} from '@/features/browser-analysis/service';
|
||||||
@@ -27,6 +31,17 @@ import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains';
|
|||||||
export const transformCapabilityHandler: CapabilityDomainHandler = {
|
export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||||
...TRANSFORM_CAPABILITY_DOMAIN,
|
...TRANSFORM_CAPABILITY_DOMAIN,
|
||||||
async handle({ method, input, grant }) {
|
async handle({ method, input, grant }) {
|
||||||
|
if (method === 'browser.transform.prepare') {
|
||||||
|
requireScope(grant, 'browser.recording.read');
|
||||||
|
requireScope(grant, 'browser.callable.execute');
|
||||||
|
return prepareCapturedBrowserTransformProfile(
|
||||||
|
await allowedTarget(grant, input),
|
||||||
|
String(input.candidateId || ''),
|
||||||
|
input.packet as BrowserTransformPacket,
|
||||||
|
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
|
||||||
|
typeof input.name === 'string' ? input.name : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (method === 'browser.packet.compare') {
|
if (method === 'browser.packet.compare') {
|
||||||
await allowedTarget(grant, input);
|
await allowedTarget(grant, input);
|
||||||
return compareBrowserPackets(
|
return compareBrowserPackets(
|
||||||
@@ -123,6 +138,17 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
|
|||||||
await allowedTarget(grant, profile.target);
|
await allowedTarget(grant, profile.target);
|
||||||
return deleteBrowserTransformProfile(profile.id);
|
return deleteBrowserTransformProfile(profile.id);
|
||||||
}
|
}
|
||||||
|
if (method === 'browser.transform.validation.execute') {
|
||||||
|
requireScope(grant, 'browser.transform.execute');
|
||||||
|
const executeValidationInput = input as unknown as BrowserTransformValidationExecuteInput;
|
||||||
|
const draft = await browserTransformValidationById(executeValidationInput.validationId);
|
||||||
|
await allowedTarget(grant, draft.profile.target);
|
||||||
|
return executeBrowserTransformValidation(
|
||||||
|
executeValidationInput.validationId,
|
||||||
|
executeValidationInput.direction,
|
||||||
|
executeValidationInput.packet,
|
||||||
|
);
|
||||||
|
}
|
||||||
const executeInput = input as unknown as BrowserTransformExecuteInput;
|
const executeInput = input as unknown as BrowserTransformExecuteInput;
|
||||||
const profile = await getBrowserTransformProfile(executeInput.profileId);
|
const profile = await getBrowserTransformProfile(executeInput.profileId);
|
||||||
await allowedTarget(grant, profile.target);
|
await allowedTarget(grant, profile.target);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import { scriptingTarget } from '@/platform/browser/targets';
|
||||||
|
import { ExtensionError } from '@/shared/errors';
|
||||||
|
import type { BrowserTarget, PageDialog } from '@/types/models';
|
||||||
|
|
||||||
|
export function installPageDialogCapture(): boolean {
|
||||||
|
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
|
||||||
|
const existing = Reflect.get(globalThis, key) as { messages?: unknown[] } | undefined;
|
||||||
|
if (existing?.messages) return false;
|
||||||
|
const messages: PageDialog[] = [];
|
||||||
|
const original = {
|
||||||
|
alert: globalThis.alert,
|
||||||
|
confirm: globalThis.confirm,
|
||||||
|
prompt: globalThis.prompt,
|
||||||
|
};
|
||||||
|
const record = (type: PageDialog['type'], value: unknown, decision: PageDialog['decision']): void => {
|
||||||
|
if (messages.length >= 20) return;
|
||||||
|
let message = '';
|
||||||
|
try { message = String(value ?? ''); } catch { message = '[unprintable]'; }
|
||||||
|
messages.push({ type, message: message.slice(0, 1_000), decision, timestamp: Date.now() });
|
||||||
|
};
|
||||||
|
const replacements = {
|
||||||
|
alert(value?: unknown) {
|
||||||
|
record('alert', value, 'auto_dismissed');
|
||||||
|
},
|
||||||
|
confirm(value?: unknown) {
|
||||||
|
record('confirm', value, 'auto_accepted');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
prompt(value?: unknown, defaultValue?: string) {
|
||||||
|
record('prompt', value, 'auto_submitted');
|
||||||
|
return defaultValue || '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
globalThis.alert = replacements.alert;
|
||||||
|
globalThis.confirm = replacements.confirm;
|
||||||
|
globalThis.prompt = replacements.prompt;
|
||||||
|
Reflect.set(globalThis, key, { messages, original, replacements });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
globalThis.alert = original.alert;
|
||||||
|
globalThis.confirm = original.confirm;
|
||||||
|
globalThis.prompt = original.prompt;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePageDialogCapture(): PageDialog[] {
|
||||||
|
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
|
||||||
|
const capture = Reflect.get(globalThis, key) as {
|
||||||
|
messages?: PageDialog[];
|
||||||
|
original?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
|
||||||
|
replacements?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
|
||||||
|
} | undefined;
|
||||||
|
if (!capture?.original || !capture.replacements) return [];
|
||||||
|
if (globalThis.alert === capture.replacements.alert) globalThis.alert = capture.original.alert;
|
||||||
|
if (globalThis.confirm === capture.replacements.confirm) globalThis.confirm = capture.original.confirm;
|
||||||
|
if (globalThis.prompt === capture.replacements.prompt) globalThis.prompt = capture.original.prompt;
|
||||||
|
Reflect.deleteProperty(globalThis, key);
|
||||||
|
return Array.isArray(capture.messages) ? capture.messages.slice(0, 20) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginPageDialogCapture(target: BrowserTarget): Promise<boolean> {
|
||||||
|
const result = await browser.scripting.executeScript({
|
||||||
|
target: scriptingTarget(target),
|
||||||
|
world: 'MAIN',
|
||||||
|
func: installPageDialogCapture,
|
||||||
|
});
|
||||||
|
if (result.length !== 1 || typeof result[0].result !== 'boolean') {
|
||||||
|
throw new ExtensionError('dialog_capture_unavailable', '无法安全处理页面弹窗,未执行页面操作');
|
||||||
|
}
|
||||||
|
return result[0].result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endPageDialogCapture(target: BrowserTarget, owned: boolean): Promise<PageDialog[]> {
|
||||||
|
if (!owned) return [];
|
||||||
|
try {
|
||||||
|
const result = await browser.scripting.executeScript({
|
||||||
|
target: scriptingTarget(target),
|
||||||
|
world: 'MAIN',
|
||||||
|
func: restorePageDialogCapture,
|
||||||
|
});
|
||||||
|
return Array.isArray(result[0]?.result) ? result[0].result as PageDialog[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { browser } from 'wxt/browser';
|
|||||||
import type {
|
import type {
|
||||||
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
|
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
|
||||||
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
|
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
|
||||||
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
|
PageDialog, PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import { executePageOperation } from '@/features/page-context/execution-adapter';
|
import { executePageOperation } from '@/features/page-context/execution-adapter';
|
||||||
import { getFrameInventory } from '@/features/page-context/frames';
|
import { getFrameInventory } from '@/features/page-context/frames';
|
||||||
@@ -12,6 +12,7 @@ import { listCookies } from '@/features/cookies/service';
|
|||||||
import { ExtensionError } from '@/shared/errors';
|
import { ExtensionError } from '@/shared/errors';
|
||||||
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
||||||
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
|
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
|
||||||
|
import { beginPageDialogCapture, endPageDialogCapture } from './dialogs';
|
||||||
|
|
||||||
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
|
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
|
||||||
const MAX_SCANNED_ELEMENTS = 10_000;
|
const MAX_SCANNED_ELEMENTS = 10_000;
|
||||||
@@ -738,7 +739,13 @@ function operateRegisteredNode(input: { captureId: string; nodeId: string; opera
|
|||||||
if (input.operation === 'inspect') return { ok: true as const, node };
|
if (input.operation === 'inspect') return { ok: true as const, node };
|
||||||
const control = element as HTMLInputElement;
|
const control = element as HTMLInputElement;
|
||||||
if (input.operation === 'click') {
|
if (input.operation === 'click') {
|
||||||
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
|
const style = getComputedStyle(element);
|
||||||
|
const visible = element.getClientRects().length > 0
|
||||||
|
&& style.display !== 'none'
|
||||||
|
&& style.visibility !== 'hidden'
|
||||||
|
&& style.opacity !== '0'
|
||||||
|
&& style.pointerEvents !== 'none';
|
||||||
|
if (!visible || control.disabled || element.getAttribute('aria-disabled') === 'true') {
|
||||||
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
|
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
|
||||||
}
|
}
|
||||||
const click = (element as HTMLElement).click;
|
const click = (element as HTMLElement).click;
|
||||||
@@ -807,8 +814,17 @@ export async function actOnPageNode(
|
|||||||
input: BrowserTarget | number,
|
input: BrowserTarget | number,
|
||||||
value?: string,
|
value?: string,
|
||||||
): Promise<PageNodeActionResult> {
|
): Promise<PageNodeActionResult> {
|
||||||
const node = await operateNode(captureId, nodeId, action, input, value);
|
const target = await resolveDocumentTarget(input);
|
||||||
return { action, completedAt: Date.now(), node };
|
const dialogCaptureOwned = action === 'click' ? await beginPageDialogCapture(target) : false;
|
||||||
|
let node: PageNodeDetails;
|
||||||
|
let dialogs: PageDialog[] = [];
|
||||||
|
try {
|
||||||
|
node = await operateNode(captureId, nodeId, action, target, value);
|
||||||
|
if (dialogCaptureOwned) await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
|
||||||
|
} finally {
|
||||||
|
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
|
||||||
|
}
|
||||||
|
return { action, status: 'dispatched', dispatchedAt: Date.now(), node, dialogs };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
|
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const harness = vi.hoisted(() => ({
|
|||||||
state: undefined as ExtensionState | undefined,
|
state: undefined as ExtensionState | undefined,
|
||||||
sourceRules: new Map<string, NormalizedProxyRule[]>(),
|
sourceRules: new Map<string, NormalizedProxyRule[]>(),
|
||||||
proxySet: vi.fn(async (_details: unknown) => undefined),
|
proxySet: vi.fn(async (_details: unknown) => undefined),
|
||||||
|
proxyClear: vi.fn(async (_details: unknown) => undefined),
|
||||||
proxyGet: vi.fn(async (_details: unknown) => ({
|
proxyGet: vi.fn(async (_details: unknown) => ({
|
||||||
value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension',
|
value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension',
|
||||||
})),
|
})),
|
||||||
@@ -24,7 +25,7 @@ const harness = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('wxt/browser', () => ({
|
vi.mock('wxt/browser', () => ({
|
||||||
browser: {
|
browser: {
|
||||||
proxy: { settings: { get: harness.proxyGet, set: harness.proxySet } },
|
proxy: { settings: { get: harness.proxyGet, set: harness.proxySet, clear: harness.proxyClear } },
|
||||||
storage: {
|
storage: {
|
||||||
session: { get: harness.sessionGet, set: harness.sessionSet },
|
session: { get: harness.sessionGet, set: harness.sessionSet },
|
||||||
onChanged: { addListener: vi.fn() },
|
onChanged: { addListener: vi.fn() },
|
||||||
@@ -60,6 +61,7 @@ vi.mock('./repository', () => ({
|
|||||||
import {
|
import {
|
||||||
applyProxyRules, importProxyConfiguration, refreshProxyRuleSource, removeProxyProfile,
|
applyProxyRules, importProxyConfiguration, refreshProxyRuleSource, removeProxyProfile,
|
||||||
routeCurrentSite, saveProxyProfile, saveProxyRuleSource, setProxyAuthPassword, switchProxy,
|
routeCurrentSite, saveProxyProfile, saveProxyRuleSource, setProxyAuthPassword, switchProxy,
|
||||||
|
getProxyStatus, releaseProxy, proxyConfigMatches,
|
||||||
} from './service';
|
} from './service';
|
||||||
|
|
||||||
function baseState(): ExtensionState {
|
function baseState(): ExtensionState {
|
||||||
@@ -120,12 +122,41 @@ describe('proxy service', () => {
|
|||||||
harness.state = baseState();
|
harness.state = baseState();
|
||||||
harness.sourceRules.clear();
|
harness.sourceRules.clear();
|
||||||
vi.clearAllMocks();
|
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({
|
harness.proxyGet.mockResolvedValue({
|
||||||
value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension',
|
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 () => {
|
it('applies automatic routing and commits the exact PAC revision', async () => {
|
||||||
const rules: NormalizedProxyRule[] = Array.from({ length: 2_000 }, (_, ordinal) => ({
|
const rules: NormalizedProxyRule[] = Array.from({ length: 2_000 }, (_, ordinal) => ({
|
||||||
sourceId: 'source', ordinal, condition: { type: 'host_wildcard', value: `*.d${ordinal}.example` },
|
sourceId: 'source', ordinal, condition: { type: 'host_wildcard', value: `*.d${ordinal}.example` },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { browser } from 'wxt/browser';
|
|||||||
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY } from '@/protocol/storage';
|
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY } from '@/protocol/storage';
|
||||||
import type {
|
import type {
|
||||||
ExtensionState, ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePage, ProxyRulePreview,
|
ExtensionState, ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePage, ProxyRulePreview,
|
||||||
ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput,
|
ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput, ProxyStatus,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import { getState, updateState } from '@/platform/storage/state';
|
import { getState, updateState } from '@/platform/storage/state';
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +32,10 @@ const authPasswords = new Map<string, string>();
|
|||||||
const sourceRefreshes = new Map<string, { identity: string; promise: Promise<ExtensionState> }>();
|
const sourceRefreshes = new Map<string, { identity: string; promise: Promise<ExtensionState> }>();
|
||||||
let proxyState: ExtensionState | undefined;
|
let proxyState: ExtensionState | undefined;
|
||||||
|
|
||||||
|
browser.proxy?.settings?.onChange?.addListener(() => {
|
||||||
|
void browser.runtime.sendMessage({ action: 'proxy.status.changed' }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
function isFirefox(): boolean {
|
function isFirefox(): boolean {
|
||||||
return Boolean(import.meta.env.FIREFOX);
|
return Boolean(import.meta.env.FIREFOX);
|
||||||
}
|
}
|
||||||
@@ -119,7 +123,7 @@ async function assertProxyControl(): Promise<void> {
|
|||||||
throw new Error('浏览器代理正由其他扩展控制,请先停用其他代理扩展后重试');
|
throw new Error('浏览器代理正由其他扩展控制,请先停用其他代理扩展后重试');
|
||||||
}
|
}
|
||||||
if (current.levelOfControl === 'not_controllable') {
|
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,
|
value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig,
|
||||||
scope: 'regular',
|
scope: 'regular',
|
||||||
});
|
});
|
||||||
|
await verifyProxyControl({ proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await browser.proxy.settings.set({
|
await browser.proxy.settings.set({
|
||||||
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
|
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
|
||||||
scope: 'regular',
|
scope: 'regular',
|
||||||
});
|
});
|
||||||
|
await verifyProxyControl({ mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setBrowserProxyProfile(profile: ProxyProfile): Promise<void> {
|
async function setBrowserProxyProfile(profile: ProxyProfile): Promise<void> {
|
||||||
await assertProxyControl();
|
await assertProxyControl();
|
||||||
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
|
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
|
||||||
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
|
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> {
|
async function compilationInput(state: ExtensionState, withRules = true): Promise<ProxyCompilationInput> {
|
||||||
@@ -213,17 +288,17 @@ export async function saveProxyProfile(profile: ProxyProfile): Promise<Extension
|
|||||||
...current,
|
...current,
|
||||||
proxyProfiles: [...current.proxyProfiles.filter((item) => item.id !== canonical.id), canonical],
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeProxyProfile(profileId: string): Promise<ExtensionState> {
|
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);
|
const profile = current.proxyProfiles.find((item) => item.id === profileId);
|
||||||
if (!profile) throw new Error('代理配置不存在');
|
if (!profile) throw new Error('代理配置不存在');
|
||||||
if (RESERVED_PROXY_PROFILE_IDS.has(profileId) || profile.builtin) 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)
|
if (current.proxyRules.some((rule) => rule.proxyProfileId === profileId)
|
||||||
|| current.proxyRuleSources.some((source) => source.matchProfileId === profileId || source.bypassProfileId === profileId)
|
|| current.proxyRuleSources.some((source) => source.matchProfileId === profileId || source.bypassProfileId === profileId)
|
||||||
|| current.proxyRouting.defaultProfileId === profileId) {
|
|| current.proxyRouting.defaultProfileId === profileId) {
|
||||||
@@ -232,6 +307,7 @@ export async function removeProxyProfile(profileId: string): Promise<ExtensionSt
|
|||||||
return dirtyProxyState({
|
return dirtyProxyState({
|
||||||
...current,
|
...current,
|
||||||
proxyProfiles: current.proxyProfiles.filter((item) => item.id !== profileId),
|
proxyProfiles: current.proxyProfiles.filter((item) => item.id !== profileId),
|
||||||
|
activeProxyId: current.activeProxyId === profileId ? '' : current.activeProxyId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await setProxyAuthPassword(profileId, '');
|
await setProxyAuthPassword(profileId, '');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { request } from '@/platform/messaging/runtime';
|
|||||||
import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models';
|
import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models';
|
||||||
import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation';
|
import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation';
|
||||||
import type { ProxyViewProps } from './types';
|
import type { ProxyViewProps } from './types';
|
||||||
|
import { useProxyStatus } from './ProxyStatusBar';
|
||||||
import './proxy-workspace.css';
|
import './proxy-workspace.css';
|
||||||
|
|
||||||
const ROW_HEIGHT = 58;
|
const ROW_HEIGHT = 58;
|
||||||
@@ -47,6 +48,7 @@ function conditionHint(type: ProxyConditionType): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewProps) {
|
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 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 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));
|
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 visibleCount = Math.ceil(LIST_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2;
|
||||||
const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount);
|
const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount);
|
||||||
const enabledSources = state.proxyRuleSources.filter((source) => source.enabled && source.revision);
|
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 save = () => run(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ProxyProfile } from '@/types/models';
|
|||||||
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
|
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
|
||||||
import type { ProxyViewProps } from './types';
|
import type { ProxyViewProps } from './types';
|
||||||
import './proxy-workspace.css';
|
import './proxy-workspace.css';
|
||||||
|
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from './ProxyStatusBar';
|
||||||
|
|
||||||
function createProfile(): ProxyProfile {
|
function createProfile(): ProxyProfile {
|
||||||
return {
|
return {
|
||||||
@@ -17,6 +18,7 @@ function createProfile(): ProxyProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) {
|
export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) {
|
||||||
|
const status = useProxyStatus(state);
|
||||||
const [draft, setDraft] = useState<ProxyProfile>(() => state.proxyProfiles[0] || createProfile());
|
const [draft, setDraft] = useState<ProxyProfile>(() => state.proxyProfiles[0] || createProfile());
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordConfigured, setPasswordConfigured] = useState(false);
|
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>
|
<Button variant="primary" onClick={() => setDraft(createProfile())}><Plus size={16} />新建出口</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProxyStatusBar status={status} />
|
||||||
<div className="proxy-profile-workspace">
|
<div className="proxy-profile-workspace">
|
||||||
<section className="proxy-profile-index" aria-label="代理出口列表">
|
<section className="proxy-profile-index" aria-label="代理出口列表">
|
||||||
<div className="proxy-panel-label"><span>出口</span><strong>{state.proxyProfiles.length}</strong></div>
|
<div className="proxy-panel-label"><span>出口</span><strong>{state.proxyProfiles.length}</strong></div>
|
||||||
<div className="proxy-profile-list">
|
<div className="proxy-profile-list">
|
||||||
|
<StartupProxyOption state={state} status={status} setState={setState} run={run} busy={busy} />
|
||||||
{state.proxyProfiles.map((profile) => <button
|
{state.proxyProfiles.map((profile) => <button
|
||||||
key={profile.id}
|
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] })}
|
onClick={() => setDraft({ ...profile, bypass: [...profile.bypass] })}
|
||||||
>
|
>
|
||||||
<span className="proxy-profile-icon"><Network size={16} /></span>
|
<span className="proxy-profile-icon"><Network size={16} /></span>
|
||||||
<span><strong>{profile.name}</strong><small>{proxyProfileDetail(profile)}</small></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} />
|
<ChevronRight size={15} />
|
||||||
</button>)}
|
</button>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +89,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
|
|||||||
<section className="proxy-profile-editor">
|
<section className="proxy-profile-editor">
|
||||||
<div className="proxy-editor-heading">
|
<div className="proxy-editor-heading">
|
||||||
<div><span>{draft.builtin ? '内置出口' : '自定义出口'}</span><h2>{draft.name}</h2></div>
|
<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>
|
||||||
<div className="proxy-form-grid">
|
<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>
|
<Field label="名称"><input value={draft.name} disabled={draft.id === 'direct' || draft.id === 'system'} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -31,7 +31,7 @@ export const DEFAULT_STATE: ExtensionState = {
|
|||||||
proxyRuleSources: [],
|
proxyRuleSources: [],
|
||||||
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
|
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
|
||||||
proxyRuntime: { dirty: false, compiledBytes: 0, manualRuleCount: 0, sourceRuleCount: 0, warnings: [] },
|
proxyRuntime: { dirty: false, compiledBytes: 0, manualRuleCount: 0, sourceRuleCount: 0, warnings: [] },
|
||||||
activeProxyId: 'direct',
|
activeProxyId: '',
|
||||||
customUserAgentProfiles: [],
|
customUserAgentProfiles: [],
|
||||||
userAgentAssignments: [],
|
userAgentAssignments: [],
|
||||||
bridge: {
|
bridge: {
|
||||||
@@ -109,6 +109,11 @@ function normalizeManagedInstance(input: unknown): BridgeConfig['managedInstance
|
|||||||
return value as NonNullable<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 {
|
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||||
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
|
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
|
||||||
const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
|
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 : {}),
|
...(value.proxyRuntime && typeof value.proxyRuntime === 'object' ? value.proxyRuntime : {}),
|
||||||
warnings: Array.isArray(value.proxyRuntime?.warnings) ? value.proxyRuntime.warnings.slice(0, 100) : [],
|
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!
|
? value.activeProxyId!
|
||||||
: 'direct',
|
: '',
|
||||||
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
|
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
|
||||||
userAgentAssignments: userAgentState.userAgentAssignments,
|
userAgentAssignments: userAgentState.userAgentAssignments,
|
||||||
bridge: {
|
bridge: {
|
||||||
...DEFAULT_STATE.bridge,
|
...DEFAULT_STATE.bridge,
|
||||||
...value.bridge,
|
...value.bridge,
|
||||||
|
browserName: normalizeBrowserMetadata(value.bridge?.browserName, 120),
|
||||||
|
browserVersion: normalizeBrowserMetadata(value.bridge?.browserVersion, 80),
|
||||||
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
|
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
|
||||||
},
|
},
|
||||||
floatingPanel: {
|
floatingPanel: {
|
||||||
@@ -259,6 +266,7 @@ export async function setState(input: ExtensionState): Promise<ExtensionState> {
|
|||||||
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
|
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
|
||||||
proxyRuleSources: state.proxyRuleSources, proxyRouting: state.proxyRouting,
|
proxyRuleSources: state.proxyRuleSources, proxyRouting: state.proxyRouting,
|
||||||
proxyRuntime: state.proxyRuntime, activeProxyId: state.activeProxyId,
|
proxyRuntime: state.proxyRuntime, activeProxyId: state.activeProxyId,
|
||||||
|
startupProxy: state.startupProxy,
|
||||||
},
|
},
|
||||||
[USER_AGENT_SETTINGS_STORAGE_KEY]: {
|
[USER_AGENT_SETTINGS_STORAGE_KEY]: {
|
||||||
customUserAgentProfiles: state.customUserAgentProfiles,
|
customUserAgentProfiles: state.customUserAgentProfiles,
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ describe('Bridge v3 protocol', () => {
|
|||||||
expect(parseCapabilityParams('browser.recording.trace.list', {
|
expect(parseCapabilityParams('browser.recording.trace.list', {
|
||||||
tabId: 12, frameId: 0, limit: 20,
|
tabId: 12, frameId: 0, limit: 20,
|
||||||
})).toMatchObject({ limit: 20 });
|
})).toMatchObject({ limit: 20 });
|
||||||
|
expect(parseCapabilityParams('browser.crypto.inspect', {
|
||||||
|
tabId: 12, captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000,
|
||||||
|
})).toMatchObject({ captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000 });
|
||||||
|
expect(() => parseCapabilityParams('browser.crypto.inspect', {
|
||||||
|
captureId: 'capture-1', nodeId: 'n1', settleMs: 30_000,
|
||||||
|
})).toThrow();
|
||||||
expect(parseCapabilityParams('browser.recording.evidence.inspect', {
|
expect(parseCapabilityParams('browser.recording.evidence.inspect', {
|
||||||
tabId: 12, traceId: 'trace-1', includeValues: false,
|
tabId: 12, traceId: 'trace-1', includeValues: false,
|
||||||
})).toMatchObject({ traceId: 'trace-1', includeValues: false });
|
})).toMatchObject({ traceId: 'trace-1', includeValues: false });
|
||||||
@@ -163,6 +169,12 @@ describe('Bridge v3 protocol', () => {
|
|||||||
candidateId: 'candidate-1',
|
candidateId: 'candidate-1',
|
||||||
callableId: 'callable-1',
|
callableId: 'callable-1',
|
||||||
});
|
});
|
||||||
|
expect(parseCapabilityParams('browser.transform.prepare', {
|
||||||
|
tabId: 12,
|
||||||
|
candidateId: 'candidate-1',
|
||||||
|
inputPaths: ['body'],
|
||||||
|
packet,
|
||||||
|
})).toMatchObject({ candidateId: 'candidate-1', packet });
|
||||||
expect(parseCapabilityParams('browser.transform.recovery.start', {
|
expect(parseCapabilityParams('browser.transform.recovery.start', {
|
||||||
id: 'profile-1',
|
id: 'profile-1',
|
||||||
})).toMatchObject({ id: 'profile-1' });
|
})).toMatchObject({ id: 'profile-1' });
|
||||||
@@ -186,6 +198,11 @@ describe('Bridge v3 protocol', () => {
|
|||||||
id: 'profile-1',
|
id: 'profile-1',
|
||||||
validationId: 'validation-1',
|
validationId: 'validation-1',
|
||||||
})).toMatchObject({ validationId: 'validation-1' });
|
})).toMatchObject({ validationId: 'validation-1' });
|
||||||
|
expect(parseCapabilityParams('browser.transform.validation.execute', {
|
||||||
|
validationId: 'validation-1',
|
||||||
|
direction: 'request',
|
||||||
|
packet,
|
||||||
|
})).toMatchObject({ validationId: 'validation-1', direction: 'request' });
|
||||||
expect(() => parseCapabilityParams('browser.profile.validate', {
|
expect(() => parseCapabilityParams('browser.profile.validate', {
|
||||||
tabId: 12,
|
tabId: 12,
|
||||||
profile: {},
|
profile: {},
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export const capabilityParams = {
|
|||||||
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
|
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
|
||||||
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
|
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
|
||||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||||
|
'browser.crypto.inspect': v.strictObject({
|
||||||
|
...targetFields,
|
||||||
|
captureId,
|
||||||
|
nodeId,
|
||||||
|
settleMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(5_000))),
|
||||||
|
}),
|
||||||
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.instance.close': v.optional(v.strictObject({})),
|
'browser.instance.close': v.optional(v.strictObject({})),
|
||||||
@@ -230,6 +236,13 @@ export const capabilityParams = {
|
|||||||
observed: v.optional(browserTransformPacketSchema),
|
observed: v.optional(browserTransformPacketSchema),
|
||||||
comparisonMode: v.optional(v.picklist(['structure', 'exact'])),
|
comparisonMode: v.optional(v.picklist(['structure', 'exact'])),
|
||||||
}),
|
}),
|
||||||
|
'browser.transform.prepare': v.strictObject({
|
||||||
|
...targetFields,
|
||||||
|
candidateId: id,
|
||||||
|
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
|
||||||
|
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||||
|
packet: browserTransformPacketSchema,
|
||||||
|
}),
|
||||||
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
||||||
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
||||||
@@ -252,6 +265,11 @@ export const capabilityParams = {
|
|||||||
'browser.transform.recovery.confirm': v.strictObject({ id, validationId: id }),
|
'browser.transform.recovery.confirm': v.strictObject({ id, validationId: id }),
|
||||||
'browser.transform.recovery.reset': v.strictObject({ id }),
|
'browser.transform.recovery.reset': v.strictObject({ id }),
|
||||||
'browser.transform.execute': browserTransformExecuteSchema,
|
'browser.transform.execute': browserTransformExecuteSchema,
|
||||||
|
'browser.transform.validation.execute': v.strictObject({
|
||||||
|
validationId: id,
|
||||||
|
direction: v.picklist(['request', 'response']),
|
||||||
|
packet: browserTransformPacketSchema,
|
||||||
|
}),
|
||||||
'browser.invoke': v.strictObject({
|
'browser.invoke': v.strictObject({
|
||||||
...targetFields,
|
...targetFields,
|
||||||
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ describe('versioned Bridge capability catalog', () => {
|
|||||||
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
|
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
|
||||||
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
|
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
|
||||||
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
|
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
|
||||||
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.crypto.inspect')).toMatchObject({
|
||||||
|
domain: 'recording',
|
||||||
|
access: 'execute',
|
||||||
|
scopes: [
|
||||||
|
'browser.dom.write',
|
||||||
|
'browser.recording.control',
|
||||||
|
'browser.recording.sensitive.read',
|
||||||
|
'browser.network.capture',
|
||||||
|
'browser.network.sensitive.read',
|
||||||
|
],
|
||||||
|
targetMode: 'document',
|
||||||
|
});
|
||||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.thumbnail')).toMatchObject({
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.thumbnail')).toMatchObject({
|
||||||
agentVisible: false,
|
agentVisible: false,
|
||||||
});
|
});
|
||||||
@@ -63,6 +75,14 @@ describe('versioned Bridge capability catalog', () => {
|
|||||||
targetMode: 'profile',
|
targetMode: 'profile',
|
||||||
});
|
});
|
||||||
expect(capabilityBaseScope('browser.transform.recovery.validate')).toBe('browser.transform.execute');
|
expect(capabilityBaseScope('browser.transform.recovery.validate')).toBe('browser.transform.execute');
|
||||||
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.validation.execute')).toMatchObject({
|
||||||
|
domain: 'transform',
|
||||||
|
access: 'execute',
|
||||||
|
scopes: ['browser.transform.execute'],
|
||||||
|
targetMode: 'profile',
|
||||||
|
});
|
||||||
|
expect(capabilityVisibleToAgent('browser.transform.validation.execute')).toBe(true);
|
||||||
|
expect(capabilityVisibleToAgent('browser.transform.prepare')).toBe(true);
|
||||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.proof')).toMatchObject({
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.proof')).toMatchObject({
|
||||||
domain: 'isolation',
|
domain: 'isolation',
|
||||||
access: 'sensitive-read',
|
access: 'sensitive-read',
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ const CAPABILITY_METADATA = {
|
|||||||
domain: 'page', access: 'write', summary: '点击、聚焦、滚动或填写稳定页面节点',
|
domain: 'page', access: 'write', summary: '点击、聚焦、滚动或填写稳定页面节点',
|
||||||
scopes: ['browser.dom.write'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.dom.write'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
|
'browser.crypto.inspect': {
|
||||||
|
domain: 'recording', access: 'execute',
|
||||||
|
summary: '原子化触发一个可见页面节点,返回加解密、编码、网络证据及动作后的新页面节点;以非阻塞本地默认值处理 alert/confirm/prompt,并为明文转换准备候选',
|
||||||
|
scopes: [
|
||||||
|
'browser.dom.write',
|
||||||
|
'browser.recording.control',
|
||||||
|
'browser.recording.sensitive.read',
|
||||||
|
'browser.network.capture',
|
||||||
|
'browser.network.sensitive.read',
|
||||||
|
],
|
||||||
|
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
'browser.cookies': {
|
'browser.cookies': {
|
||||||
domain: 'page', access: 'sensitive-read', summary: '读取目标页面 Cookie,包括已授权的 HttpOnly 值',
|
domain: 'page', access: 'sensitive-read', summary: '读取目标页面 Cookie,包括已授权的 HttpOnly 值',
|
||||||
scopes: ['browser.cookies.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.cookies.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
@@ -162,7 +174,7 @@ const CAPABILITY_METADATA = {
|
|||||||
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.recording.start': {
|
'browser.recording.start': {
|
||||||
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;生成新明文网关时先录制一次真实业务操作,再检查候选证据',
|
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;由插件本地诊断与高层能力使用',
|
||||||
scopes: ['browser.recording.control'],
|
scopes: ['browser.recording.control'],
|
||||||
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'captureValues=true' }],
|
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'captureValues=true' }],
|
||||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
@@ -194,7 +206,7 @@ const CAPABILITY_METADATA = {
|
|||||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.callable.create': {
|
'browser.callable.create': {
|
||||||
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数;生成明文网关 Profile 前需要得到可回放函数',
|
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数',
|
||||||
scopes: ['browser.callable.execute'],
|
scopes: ['browser.callable.execute'],
|
||||||
conditionalScopes: [{ scope: 'browser.debugger.control', when: 'source=deep-capture' }],
|
conditionalScopes: [{ scope: 'browser.debugger.control', when: 'source=deep-capture' }],
|
||||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
@@ -240,7 +252,7 @@ const CAPABILITY_METADATA = {
|
|||||||
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.transform.profile.list': {
|
'browser.transform.profile.list': {
|
||||||
domain: 'transform', access: 'read', summary: '明文网关入口:先列出目标页面已有 Profile;已有配置可直接用 transform.execute,无配置再走录制、提案和验证',
|
domain: 'transform', access: 'read', summary: '列出目标页面已有的明文转换;无匹配配置时使用 browser.crypto.inspect 和 browser.transform.prepare',
|
||||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.transform.profile.delete': {
|
'browser.transform.profile.delete': {
|
||||||
@@ -277,24 +289,34 @@ const CAPABILITY_METADATA = {
|
|||||||
domain: 'transform', access: 'execute', summary: '使用已保存的 Profile 对 HTTP 报文执行请求加密或响应解密;它不是网络代理切换',
|
domain: 'transform', access: 'execute', summary: '使用已保存的 Profile 对 HTTP 报文执行请求加密或响应解密;它不是网络代理切换',
|
||||||
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
|
'browser.transform.validation.execute': {
|
||||||
|
domain: 'transform', access: 'execute', summary: '使用 Agent 已验证的短时草稿执行请求加密或响应解密,不会永久保存 Profile',
|
||||||
|
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
'browser.packet.compare': {
|
'browser.packet.compare': {
|
||||||
domain: 'transform', access: 'read', summary: '按结构或精确模式比较两份 HTTP 报文',
|
domain: 'transform', access: 'read', summary: '按结构或精确模式比较两份 HTTP 报文',
|
||||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.profile.propose': {
|
'browser.profile.propose': {
|
||||||
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案;下一步必须调用 profile.validate',
|
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案',
|
||||||
scopes: ['browser.transform.read', 'browser.recording.read'],
|
scopes: ['browser.transform.read', 'browser.recording.read'],
|
||||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.profile.validation.latest': {
|
'browser.profile.validation.latest': {
|
||||||
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态;草稿过期后需重新验证',
|
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态',
|
||||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
'browser.profile.validate': {
|
'browser.profile.validate': {
|
||||||
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;成功后只生成短时草稿,必须由用户在插件本地确认保存',
|
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;保存仍需用户在插件本地确认保存',
|
||||||
scopes: ['browser.transform.execute', 'browser.recording.read'],
|
scopes: ['browser.transform.execute', 'browser.recording.read'],
|
||||||
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
|
'browser.transform.prepare': {
|
||||||
|
domain: 'transform', access: 'execute',
|
||||||
|
summary: '将 browser.crypto.inspect 捕获的候选原子化编译并验证为短时明文转换;不需要 Agent 操作录制、页面函数或 Profile 底层步骤',
|
||||||
|
scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'],
|
||||||
|
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
'browser.invoke': {
|
'browser.invoke': {
|
||||||
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
|
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
|
||||||
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ describe('extension request schemas', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('validates manager-owned browser instance binding', () => {
|
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({
|
expect(parseExtensionRequest({
|
||||||
action: 'bridge.managed-instance.bind',
|
action: 'bridge.managed-instance.bind',
|
||||||
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
|
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ const bridgeConfig = v.strictObject({
|
|||||||
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
|
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
|
||||||
autoConnect: v.boolean(),
|
autoConnect: v.boolean(),
|
||||||
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
|
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),
|
managedInstance: v.optional(managedInstance),
|
||||||
pairedEngine: v.optional(v.strictObject({
|
pairedEngine: v.optional(v.strictObject({
|
||||||
engineIdentityId: id,
|
engineIdentityId: id,
|
||||||
@@ -301,6 +303,8 @@ const payloadSchemas = {
|
|||||||
'proxy.save': proxyProfile,
|
'proxy.save': proxyProfile,
|
||||||
'proxy.delete': v.strictObject({ id }),
|
'proxy.delete': v.strictObject({ id }),
|
||||||
'proxy.switch': v.strictObject({ id }),
|
'proxy.switch': v.strictObject({ id }),
|
||||||
|
'proxy.status': noPayload,
|
||||||
|
'proxy.release': noPayload,
|
||||||
'proxy.rule.save': proxyRule,
|
'proxy.rule.save': proxyRule,
|
||||||
'proxy.rule.delete': v.strictObject({ id }),
|
'proxy.rule.delete': v.strictObject({ id }),
|
||||||
'proxy.auto.apply': noPayload,
|
'proxy.auto.apply': noPayload,
|
||||||
@@ -471,7 +475,15 @@ const payloadSchemas = {
|
|||||||
'metrics.get': noPayload,
|
'metrics.get': noPayload,
|
||||||
'metrics.reset': noPayload,
|
'metrics.reset': noPayload,
|
||||||
'bridge.config.save': bridgeConfig,
|
'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': noPayload,
|
||||||
'bridge.pair.cancel': noPayload,
|
'bridge.pair.cancel': noPayload,
|
||||||
'bridge.pair.status': noPayload,
|
'bridge.pair.status': noPayload,
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export interface ExtensionRequestMap {
|
|||||||
'proxy.save': { input: ProxyProfile; output: ExtensionState };
|
'proxy.save': { input: ProxyProfile; output: ExtensionState };
|
||||||
'proxy.delete': { input: { id: string }; output: ExtensionState };
|
'proxy.delete': { input: { id: string }; output: ExtensionState };
|
||||||
'proxy.switch': { 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.save': { input: ProxyRule; output: ExtensionState };
|
||||||
'proxy.rule.delete': { input: { id: string }; output: ExtensionState };
|
'proxy.rule.delete': { input: { id: string }; output: ExtensionState };
|
||||||
'proxy.auto.apply': { input: undefined; output: ExtensionState };
|
'proxy.auto.apply': { input: undefined; output: ExtensionState };
|
||||||
@@ -250,7 +252,9 @@ export interface ExtensionRequestMap {
|
|||||||
'metrics.reset': { input: undefined; output: RuntimeMetrics };
|
'metrics.reset': { input: undefined; output: RuntimeMetrics };
|
||||||
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
|
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
|
||||||
'bridge.managed-instance.bind': {
|
'bridge.managed-instance.bind': {
|
||||||
input: NonNullable<BridgeConfig['managedInstance']>;
|
input: NonNullable<BridgeConfig['managedInstance']> & Pick<BridgeConfig, 'browserName' | 'browserVersion'> & {
|
||||||
|
startupProxy?: string;
|
||||||
|
};
|
||||||
output: BridgeStatus;
|
output: BridgeStatus;
|
||||||
};
|
};
|
||||||
'bridge.pair': { input: undefined; output: BridgePairingStatus };
|
'bridge.pair': { input: undefined; output: BridgePairingStatus };
|
||||||
|
|||||||
+28
-1
@@ -113,6 +113,13 @@ export interface ProxyRulePage {
|
|||||||
rules: NormalizedProxyRule[];
|
rules: NormalizedProxyRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyStatus {
|
||||||
|
followingStartup?: boolean;
|
||||||
|
control: string;
|
||||||
|
label: string;
|
||||||
|
activeProfileId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProxyRuntimeState {
|
export interface ProxyRuntimeState {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
compiledBytes: number;
|
compiledBytes: number;
|
||||||
@@ -196,6 +203,8 @@ export interface BridgeConfig {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
installationId: string;
|
installationId: string;
|
||||||
|
browserName?: string;
|
||||||
|
browserVersion?: string;
|
||||||
managedInstance?: {
|
managedInstance?: {
|
||||||
manager: 'ytray' | 'yakit';
|
manager: 'ytray' | 'yakit';
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -1271,7 +1280,15 @@ export interface BrowserTransformExecuteInput {
|
|||||||
packet: BrowserTransformPacket;
|
packet: BrowserTransformPacket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowserTransformValidationExecuteInput {
|
||||||
|
validationId: string;
|
||||||
|
direction: BrowserTransformDirectionName;
|
||||||
|
packet: BrowserTransformPacket;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrowserTransformExecution {
|
export interface BrowserTransformExecution {
|
||||||
|
explanation?: BrowserTransformExplanation;
|
||||||
|
proofLevel?: 'structure' | 'exact' | 'execution-only';
|
||||||
profileId: string;
|
profileId: string;
|
||||||
direction: BrowserTransformDirectionName;
|
direction: BrowserTransformDirectionName;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -1480,6 +1497,7 @@ export interface DiagnosticsBundle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionState {
|
export interface ExtensionState {
|
||||||
|
startupProxy?: string;
|
||||||
version: 7;
|
version: 7;
|
||||||
proxyProfiles: ProxyProfile[];
|
proxyProfiles: ProxyProfile[];
|
||||||
proxyRules: ProxyRule[];
|
proxyRules: ProxyRule[];
|
||||||
@@ -1732,6 +1750,13 @@ export interface PageContextDiff {
|
|||||||
|
|
||||||
export type PageNodeAction = 'click' | 'focus' | 'scroll' | 'setValue';
|
export type PageNodeAction = 'click' | 'focus' | 'scroll' | 'setValue';
|
||||||
|
|
||||||
|
export interface PageDialog {
|
||||||
|
type: 'alert' | 'confirm' | 'prompt';
|
||||||
|
message: string;
|
||||||
|
decision: 'auto_dismissed' | 'auto_accepted' | 'auto_submitted';
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageNodeDetails extends PageNodeSummary {
|
export interface PageNodeDetails extends PageNodeSummary {
|
||||||
reference: PageNodeReference;
|
reference: PageNodeReference;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -1741,8 +1766,10 @@ export interface PageNodeDetails extends PageNodeSummary {
|
|||||||
|
|
||||||
export interface PageNodeActionResult {
|
export interface PageNodeActionResult {
|
||||||
action: PageNodeAction;
|
action: PageNodeAction;
|
||||||
completedAt: number;
|
status: 'dispatched';
|
||||||
|
dispatchedAt: number;
|
||||||
node: PageNodeDetails;
|
node: PageNodeDetails;
|
||||||
|
dialogs?: PageDialog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageEvalRequest {
|
export interface PageEvalRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user