mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 12:41:53 +08:00
feat(authorization): test across ytray browser instances
This commit is contained in:
@@ -164,11 +164,6 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
request.payload.timeoutMs,
|
||||
));
|
||||
}
|
||||
case 'authorization.yakit.open':
|
||||
return ok(await engineBridge.requestEngine(
|
||||
'yakit.browser_authorization.open',
|
||||
request.payload,
|
||||
));
|
||||
case 'authorization.yakit.instances':
|
||||
return ok(await engineBridge.requestEngine('yakit.browser_authorization.instances', {}));
|
||||
case 'context.capture': {
|
||||
|
||||
@@ -238,7 +238,7 @@ function App() {
|
||||
|
||||
<div className="content-area">
|
||||
{section === 'overview' && <Overview state={state} bridge={bridge} tab={tab} navigate={navigate} run={run} busy={busy} />}
|
||||
{section === 'authorization' && <AuthorizationTestingWorkspace state={state} setState={setState} tabs={tabs} activeTab={tab} bridge={bridge} refreshTabs={refreshTabs} run={run} busy={busy} />}
|
||||
{section === 'authorization' && <AuthorizationTestingWorkspace bridge={bridge} run={run} busy={busy} />}
|
||||
{section === 'proxies' && <ProxyProfilesView state={state} setState={setState} run={run} busy={busy} tab={tab} />}
|
||||
{section === 'rules' && <AutoSwitchView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
{section === 'sources' && <RuleSourcesView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface BrowserAuthorizationWorkspace {
|
||||
mode: BrowserAuthorizationMode;
|
||||
state: 'ready' | 'conditional' | 'blocked' | 'stale';
|
||||
left: {
|
||||
deviceId: string;
|
||||
accountLabel?: string;
|
||||
origin: string;
|
||||
target: { tabId: number; frameId: number; documentId: string };
|
||||
@@ -316,6 +317,9 @@ export interface BrowserAuthorizationEvidenceValidation {
|
||||
export type BrowserAuthorizationTaskSchema =
|
||||
| 'authorization.workspace.create'
|
||||
| 'authorization.workspace.inspect'
|
||||
| 'authorization.capture.start'
|
||||
| 'authorization.capture.status'
|
||||
| 'authorization.capture.stop'
|
||||
| 'authorization.baseline.candidates'
|
||||
| 'authorization.baseline.bind'
|
||||
| 'authorization.logical.bind'
|
||||
|
||||
@@ -219,6 +219,9 @@ export function normalizeBrowserAuthorizationTaskResult<T>(
|
||||
schema: BrowserAuthorizationTaskSchema,
|
||||
value: unknown,
|
||||
): T {
|
||||
if (schema.startsWith('authorization.capture.')) {
|
||||
return objectValue(value, schema, '$') as T;
|
||||
}
|
||||
if (schema === 'authorization.baseline.candidates') {
|
||||
if (value === undefined || value === null) return [] as T;
|
||||
if (!Array.isArray(value)) mismatch(schema, '$', '数组或空值');
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Check, CircleCheck, ExternalLink, Fingerprint,
|
||||
LockKeyhole, Play, RefreshCw, RotateCcw, ShieldAlert, Square, UserRoundPlus,
|
||||
LockKeyhole, Play, RefreshCw, RotateCcw, ShieldAlert, Square,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { authorizationShareGrantInput } from '@/features/grants/gateway-share';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ActiveTabInfo, BridgeStatus, BrowserAuthorizationInstance, BrowserIsolationContext, BrowserIsolationInspection,
|
||||
ExtensionState, NetworkCaptureStatus,
|
||||
ActiveTabInfo, BridgeStatus, BrowserAuthorizationInstance, NetworkCaptureStatus,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
runBrowserAuthorizationTask,
|
||||
@@ -19,10 +17,6 @@ import {
|
||||
type BrowserAuthorizationWorkspace,
|
||||
} from '../engine';
|
||||
import './authorization-testing-workspace.css';
|
||||
import {
|
||||
authorizationIdentityOptionDisabledReason,
|
||||
normalizeAuthorizationIdentityTabSelection,
|
||||
} from './identity-selection';
|
||||
import {
|
||||
authorizationWorkspaceUIReducer,
|
||||
INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
@@ -34,15 +28,10 @@ import {
|
||||
} from './AuthorizationEvidenceWorkbench';
|
||||
import { IdentitySlot } from './IdentitySlot';
|
||||
|
||||
const SESSION_KEY = 'session.authorization-testing-workspace-ui.v1';
|
||||
const SESSION_KEY = 'session.authorization-testing-workspace-ui.v2';
|
||||
|
||||
interface AuthorizationTestingWorkspaceProps {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
tabs: ActiveTabInfo[];
|
||||
activeTab?: ActiveTabInfo;
|
||||
bridge: BridgeStatus;
|
||||
refreshTabs: () => Promise<void>;
|
||||
run: (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
busy: boolean;
|
||||
}
|
||||
@@ -63,6 +52,12 @@ function shortHost(tab?: ActiveTabInfo): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWorkspaceCapture(workspaceId: string): Promise<void> {
|
||||
await Promise.allSettled((['left', 'right'] as const).map((side) => (
|
||||
runBrowserAuthorizationTask('authorization.capture.stop', { workspaceId, side })
|
||||
)));
|
||||
}
|
||||
|
||||
function formatWorkspaceRemaining(expiresAt: number, now: number): string {
|
||||
const remainingSeconds = Math.max(0, Math.ceil((expiresAt - now) / 1_000));
|
||||
if (remainingSeconds < 60) return `${remainingSeconds} 秒`;
|
||||
@@ -70,13 +65,6 @@ function formatWorkspaceRemaining(expiresAt: number, now: number): string {
|
||||
return minutes < 60 ? `${minutes} 分钟` : `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`;
|
||||
}
|
||||
|
||||
function contextForTab(
|
||||
inspection: BrowserIsolationInspection | undefined,
|
||||
tabId: number | undefined,
|
||||
): BrowserIsolationContext | undefined {
|
||||
return inspection?.contexts.find((context) => tabId && context.tabIds.includes(tabId));
|
||||
}
|
||||
|
||||
function proofLabel(workspace?: BrowserAuthorizationWorkspace): string {
|
||||
if (!workspace) return '尚未验证';
|
||||
if (workspace.proof.level === 'strong') return '强隔离';
|
||||
@@ -212,19 +200,10 @@ function newestComparableAuthorizationPair(
|
||||
}
|
||||
|
||||
export function AuthorizationTestingWorkspace({
|
||||
state,
|
||||
setState,
|
||||
tabs,
|
||||
activeTab,
|
||||
bridge,
|
||||
refreshTabs,
|
||||
run,
|
||||
busy,
|
||||
}: AuthorizationTestingWorkspaceProps) {
|
||||
const eligibleTabs = useMemo(
|
||||
() => tabs.filter((item) => item.url.startsWith('http://') || item.url.startsWith('https://')),
|
||||
[tabs],
|
||||
);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [ui, dispatch] = useReducer(
|
||||
authorizationWorkspaceUIReducer,
|
||||
@@ -232,11 +211,12 @@ export function AuthorizationTestingWorkspace({
|
||||
);
|
||||
const {
|
||||
mode,
|
||||
leftDeviceId,
|
||||
rightDeviceId,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
inspection,
|
||||
workspace,
|
||||
candidates,
|
||||
selected,
|
||||
@@ -245,49 +225,28 @@ export function AuthorizationTestingWorkspace({
|
||||
canaryPaths,
|
||||
} = ui;
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [identityNotice, setIdentityNotice] = useState('');
|
||||
const [clock, setClock] = useState(Date.now());
|
||||
const [browserInstances, setBrowserInstances] = useState<BrowserAuthorizationInstance[]>([]);
|
||||
const [targetDeviceId, setTargetDeviceId] = useState('');
|
||||
|
||||
const leftTab = eligibleTabs.find((item) => item.id === leftTabId);
|
||||
const rightTab = eligibleTabs.find((item) => item.id === rightTabId);
|
||||
const leftContext = contextForTab(inspection, leftTabId);
|
||||
const rightContext = contextForTab(inspection, rightTabId);
|
||||
const leftIsolationContextId = leftContext?.contextId || leftTab?.isolationContextId;
|
||||
const rightIsolationContextId = rightContext?.contextId || rightTab?.isolationContextId;
|
||||
const identityContextsSeparated = Boolean(
|
||||
leftIsolationContextId
|
||||
&& rightIsolationContextId
|
||||
&& leftIsolationContextId !== rightIsolationContextId,
|
||||
);
|
||||
const leftInstance = browserInstances.find((instance) => instance.deviceId === leftDeviceId);
|
||||
const rightInstance = browserInstances.find((instance) => instance.deviceId === rightDeviceId);
|
||||
const leftTab = leftInstance?.tabs.find((item) => item.id === leftTabId);
|
||||
const rightTab = rightInstance?.tabs.find((item) => item.id === rightTabId);
|
||||
const sameOrigin = Boolean(leftTab && rightTab && tabOrigin(leftTab) === tabOrigin(rightTab));
|
||||
const capabilityReady = bridge.state === 'connected'
|
||||
&& Boolean(bridge.capabilities?.includes('yakit.browser_authorization.task'));
|
||||
const instanceDiscoveryReady = bridge.state === 'connected'
|
||||
&& Boolean(bridge.capabilities?.includes('yakit.browser_authorization.instances'));
|
||||
const targetBrowserInstance = browserInstances.find((instance) => instance.deviceId === targetDeviceId);
|
||||
|
||||
const refreshInspection = useCallback(async () => {
|
||||
const next = await request('isolation.inspect', {
|
||||
tabIds: eligibleTabs.length > 0 ? eligibleTabs.map((item) => item.id) : undefined,
|
||||
});
|
||||
dispatch({ type: 'patch', value: { inspection: next } });
|
||||
}, [eligibleTabs]);
|
||||
|
||||
const refreshBrowserInstances = useCallback(async () => {
|
||||
if (!instanceDiscoveryReady) {
|
||||
setBrowserInstances([]);
|
||||
setTargetDeviceId('');
|
||||
return [];
|
||||
}
|
||||
const result = await request('authorization.yakit.instances');
|
||||
if (!Array.isArray(result.instances) || result.instances.some((instance) => !Array.isArray(instance.tabs))) {
|
||||
throw new Error('当前 Yak 引擎不支持插件内 A/B 越权测试,请更新引擎并重新连接两个浏览器实例');
|
||||
}
|
||||
setBrowserInstances(result.instances);
|
||||
setTargetDeviceId((current) => (
|
||||
result.instances.some((instance) => !instance.current && instance.deviceId === current)
|
||||
? current
|
||||
: result.instances.find((instance) => !instance.current)?.deviceId || ''
|
||||
));
|
||||
return result.instances;
|
||||
}, [instanceDiscoveryReady]);
|
||||
|
||||
@@ -305,37 +264,42 @@ export function AuthorizationTestingWorkspace({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || workspace) return;
|
||||
const normalized = normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: eligibleTabs.map((item) => item.id),
|
||||
activeTabId: activeTab?.id,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
if (!hydrated || workspace || !browserInstances.length) return;
|
||||
const left = browserInstances.find((instance) => instance.current);
|
||||
const right = browserInstances.find((instance) => (
|
||||
!instance.current && instance.deviceId === rightDeviceId
|
||||
)) || browserInstances.find((instance) => !instance.current);
|
||||
const nextLeftTabId = left?.tabs.find((tab) => tab.id === leftTabId)?.id
|
||||
|| left?.tabs.find((tab) => tab.active)?.id
|
||||
|| left?.tabs[0]?.id;
|
||||
const nextRightTabId = right?.tabs.find((tab) => tab.id === rightTabId)?.id
|
||||
|| right?.tabs.find((tab) => tab.active)?.id
|
||||
|| right?.tabs[0]?.id;
|
||||
if (leftDeviceId === (left?.deviceId || '')
|
||||
&& rightDeviceId === (right?.deviceId || '')
|
||||
&& leftTabId === nextLeftTabId
|
||||
&& rightTabId === nextRightTabId) return;
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: {
|
||||
leftDeviceId: left?.deviceId || '',
|
||||
rightDeviceId: right?.deviceId || '',
|
||||
leftTabId: nextLeftTabId,
|
||||
rightTabId: nextRightTabId,
|
||||
},
|
||||
});
|
||||
if (normalized.leftTabId !== leftTabId || normalized.rightTabId !== rightTabId) {
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: {
|
||||
leftTabId: normalized.leftTabId,
|
||||
rightTabId: normalized.rightTabId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [activeTab?.id, eligibleTabs, hydrated, leftTabId, rightTabId, workspace]);
|
||||
}, [browserInstances, hydrated, leftDeviceId, leftTabId, rightDeviceId, rightTabId, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
const value = persistedAuthorizationWorkspaceUI(ui);
|
||||
void browser.storage.session.set({ [SESSION_KEY]: value }).catch(() => undefined);
|
||||
}, [
|
||||
canaryPaths, candidates, hydrated, leftLabel, leftTabId, mode, rightLabel, rightTabId,
|
||||
canaryPaths, candidates, hydrated, leftDeviceId, leftLabel, leftTabId, mode,
|
||||
rightDeviceId, rightLabel, rightTabId,
|
||||
selected, selectedPlanCandidateId, workspace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshInspection().catch((error) => setLocalError(errorMessage(error)));
|
||||
}, [refreshInspection]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBrowserInstances().catch((error) => setLocalError(errorMessage(error)));
|
||||
const refreshWhenVisible = () => {
|
||||
@@ -347,56 +311,17 @@ export function AuthorizationTestingWorkspace({
|
||||
return () => document.removeEventListener('visibilitychange', refreshWhenVisible);
|
||||
}, [refreshBrowserInstances]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || workspace || !leftTab || !rightTab) return;
|
||||
const reason = authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: rightTab.id,
|
||||
candidateIsolationContextId: rightIsolationContextId,
|
||||
otherTabId: leftTab.id,
|
||||
otherIsolationContextId: leftIsolationContextId,
|
||||
otherLabel: '身份 A',
|
||||
});
|
||||
if (!reason) return;
|
||||
dispatch({ type: 'patch', value: { rightTabId: undefined } });
|
||||
setIdentityNotice(
|
||||
leftTab.id === rightTab.id
|
||||
? '身份 B 已清空:同一个页面不能同时代表两个身份'
|
||||
: '身份 B 已清空:该页面与身份 A 共享同一登录态',
|
||||
);
|
||||
}, [
|
||||
hydrated,
|
||||
leftIsolationContextId,
|
||||
leftTab?.id,
|
||||
rightIsolationContextId,
|
||||
rightTab?.id,
|
||||
workspace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
void Promise.all((['left', 'right'] as const).map(async (side) => {
|
||||
const target = workspace[side].target;
|
||||
const status = await request('network.capture.status', target);
|
||||
const status = await runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.status',
|
||||
{ workspaceId: workspace.id, side },
|
||||
);
|
||||
dispatch({ type: 'capture.update', side, status });
|
||||
})).catch(() => undefined);
|
||||
}, [workspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (message: unknown) => {
|
||||
const input = message as { action?: string; payload?: { tabId?: number } };
|
||||
if (input?.action !== 'network.capture.changed') return;
|
||||
const side = input.payload?.tabId === workspace?.left.target.tabId
|
||||
? 'left'
|
||||
: input.payload?.tabId === workspace?.right.target.tabId ? 'right' : undefined;
|
||||
if (!side || !workspace) return;
|
||||
void request('network.capture.status', workspace[side].target)
|
||||
.then((status) => dispatch({ type: 'capture.update', side, status }))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return undefined;
|
||||
setClock(Date.now());
|
||||
@@ -405,6 +330,7 @@ export function AuthorizationTestingWorkspace({
|
||||
}, [workspace?.id]);
|
||||
|
||||
const resetWorkspace = async () => {
|
||||
if (workspace) await stopWorkspaceCapture(workspace.id);
|
||||
dispatch({ type: 'workspace.reset' });
|
||||
setLocalError('');
|
||||
await browser.storage.session.remove(SESSION_KEY).catch(() => undefined);
|
||||
@@ -412,133 +338,72 @@ export function AuthorizationTestingWorkspace({
|
||||
|
||||
const assignIdentityTab = (side: BrowserAuthorizationSide, nextTabId: number | undefined) => {
|
||||
setLocalError('');
|
||||
setIdentityNotice('');
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: side === 'left' ? { leftTabId: nextTabId } : { rightTabId: nextTabId },
|
||||
});
|
||||
};
|
||||
|
||||
const openIncognitoSettings = () => run(async () => {
|
||||
await browser.tabs.create({ url: `chrome://extensions/?id=${browser.runtime.id}` });
|
||||
}, '已打开扩展详情,请开启“允许在无痕模式下运行”');
|
||||
|
||||
const recheckIsolationCapability = () => run(async () => {
|
||||
await refreshTabs();
|
||||
await refreshInspection();
|
||||
}, '浏览器隔离能力已重新检测');
|
||||
|
||||
const createIsolatedIdentity = () => run(async () => {
|
||||
if (!leftTab) throw new Error('请先选择身份 A 的页面');
|
||||
const result = inspection?.browser === 'firefox'
|
||||
? await request('isolation.container.open', { url: leftTab.url, name: rightLabel || '账号 B' })
|
||||
: await request('isolation.incognito.open', { url: leftTab.url });
|
||||
await refreshTabs();
|
||||
dispatch({ type: 'patch', value: { rightTabId: result.tab.id } });
|
||||
await refreshInspection();
|
||||
}, inspection?.browser === 'firefox' ? '已创建独立 Container,请在新页面登录身份 B' : '已打开无痕身份页面,请在新页面登录身份 B');
|
||||
const assignRightInstance = (deviceId: string) => {
|
||||
const instance = browserInstances.find((item) => item.deviceId === deviceId);
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: {
|
||||
rightDeviceId: deviceId,
|
||||
rightTabId: instance?.tabs.find((tab) => tab.active)?.id || instance?.tabs[0]?.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const prepareWorkspace = () => run(async () => {
|
||||
setLocalError('');
|
||||
if (!leftTab || !rightTab) throw new Error('请选择身份 A 和身份 B 的页面');
|
||||
if (leftTab.id === rightTab.id) throw new Error('A/B 身份不能使用同一个标签页');
|
||||
if (!leftInstance || !rightInstance || !leftTab || !rightTab) throw new Error('请选择在线的 A/B 浏览器及页面');
|
||||
if (leftDeviceId === rightDeviceId) throw new Error('A/B 必须来自两个独立浏览器实例');
|
||||
if (!sameOrigin) throw new Error('A/B 页面必须属于同一站点 Origin');
|
||||
if (!capabilityReady) throw new Error('当前 Yak 引擎不支持插件授权测试任务,请更新并重新连接引擎');
|
||||
|
||||
const nextState = await request('grant.create', authorizationShareGrantInput(state, [leftTab, rightTab]));
|
||||
setState(nextState);
|
||||
const nextWorkspace = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.create',
|
||||
{
|
||||
mode,
|
||||
left: { tabId: leftTab.id, frameId: 0, accountLabel: leftLabel.trim() || '账号 A' },
|
||||
right: { tabId: rightTab.id, frameId: 0, accountLabel: rightLabel.trim() || '账号 B' },
|
||||
left: { deviceId: leftDeviceId, tabId: leftTab.id, frameId: 0, accountLabel: leftLabel.trim() || '账号 A' },
|
||||
right: { deviceId: rightDeviceId, tabId: rightTab.id, frameId: 0, accountLabel: rightLabel.trim() || '账号 B' },
|
||||
},
|
||||
);
|
||||
dispatch({ type: 'workspace.initialize', workspace: nextWorkspace });
|
||||
if (nextWorkspace.state === 'ready' || nextWorkspace.state === 'conditional') {
|
||||
const [leftStatus, rightStatus] = await Promise.all([
|
||||
request('network.capture.start', {
|
||||
...nextWorkspace.left.target,
|
||||
captureHeaders: true,
|
||||
captureBody: true,
|
||||
maxEntries: 200,
|
||||
maxBodyBytes: 64 * 1024,
|
||||
}),
|
||||
request('network.capture.start', {
|
||||
...nextWorkspace.right.target,
|
||||
captureHeaders: true,
|
||||
captureBody: true,
|
||||
maxEntries: 200,
|
||||
maxBodyBytes: 64 * 1024,
|
||||
}),
|
||||
]);
|
||||
const leftStatus = await runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.start', { workspaceId: nextWorkspace.id, side: 'left' },
|
||||
);
|
||||
let rightStatus: NetworkCaptureStatus;
|
||||
try {
|
||||
rightStatus = await runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.start', { workspaceId: nextWorkspace.id, side: 'right' },
|
||||
);
|
||||
} catch (error) {
|
||||
await stopWorkspaceCapture(nextWorkspace.id);
|
||||
throw error;
|
||||
}
|
||||
dispatch({ type: 'capture.replace', capture: { left: leftStatus, right: rightStatus } });
|
||||
}
|
||||
}, 'A/B 身份已验证,双方请求捕获已开始');
|
||||
|
||||
const openCrossBrowserWorkspace = () => run(async () => {
|
||||
const sourceTab = leftTab || activeTab || eligibleTabs[0];
|
||||
if (!sourceTab) throw new Error('当前浏览器没有可用于测试的 HTTP(S) 页面');
|
||||
if (!instanceDiscoveryReady) throw new Error('当前 Yak 版本不支持读取在线实例,请更新引擎并重新连接插件');
|
||||
if (!bridge.capabilities?.includes('yakit.browser_authorization.open')) {
|
||||
throw new Error('当前 Yak 引擎不支持打开跨浏览器工作区');
|
||||
}
|
||||
const instances = await refreshBrowserInstances();
|
||||
const target = instances.find((instance) => (
|
||||
!instance.current && instance.deviceId === targetDeviceId
|
||||
)) || instances.find((instance) => !instance.current);
|
||||
if (!target) throw new Error('没有检测到另一个在线的 YTray 浏览器实例,请确认其插件已连接同一 Yak 引擎');
|
||||
setTargetDeviceId(target.deviceId);
|
||||
await request('authorization.yakit.open', {
|
||||
tabId: sourceTab.id,
|
||||
mode,
|
||||
targetDeviceId: target.deviceId,
|
||||
});
|
||||
}, '已将两个浏览器实例带入 Yakit');
|
||||
|
||||
const refreshWorkspaceDocuments = async (): Promise<BrowserAuthorizationWorkspace> => {
|
||||
if (!workspace || !leftTab || !rightTab) throw new Error('请先建立 A/B 工作区');
|
||||
const nextState = await request('grant.refresh');
|
||||
setState(nextState);
|
||||
const grant = nextState.activeGrant;
|
||||
const leftTarget = grant?.targets.find((target) => (
|
||||
target.tabId === workspace.left.target.tabId
|
||||
&& target.frameId === workspace.left.target.frameId
|
||||
));
|
||||
const rightTarget = grant?.targets.find((target) => (
|
||||
target.tabId === workspace.right.target.tabId
|
||||
&& target.frameId === workspace.right.target.frameId
|
||||
));
|
||||
if (!leftTarget || !rightTarget) {
|
||||
throw new Error('当前共享会话已不再包含身份 A/B,请重新建立工作区');
|
||||
}
|
||||
const documentChanged = (
|
||||
leftTarget.documentId !== workspace.left.target.documentId
|
||||
|| rightTarget.documentId !== workspace.right.target.documentId
|
||||
);
|
||||
if (!documentChanged && workspace.expiresAt > Date.now()) return workspace;
|
||||
|
||||
if (!workspace) throw new Error('请先建立 A/B 工作区');
|
||||
const renewed = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.create',
|
||||
{
|
||||
mode: workspace.mode,
|
||||
left: {
|
||||
tabId: leftTab.id,
|
||||
frameId: 0,
|
||||
accountLabel: workspace.left.accountLabel || leftLabel.trim() || '账号 A',
|
||||
},
|
||||
right: {
|
||||
tabId: rightTab.id,
|
||||
frameId: 0,
|
||||
accountLabel: workspace.right.accountLabel || rightLabel.trim() || '账号 B',
|
||||
},
|
||||
},
|
||||
'authorization.workspace.inspect', { workspaceId: workspace.id, revalidate: true },
|
||||
);
|
||||
dispatch({ type: 'workspace.initialize', workspace: renewed });
|
||||
if (renewed.state === 'stale' || renewed.state === 'blocked') {
|
||||
throw new Error(renewed.recovery?.message || 'A/B 页面已变化,请新建工作区后重试');
|
||||
}
|
||||
dispatch({ type: 'workspace.updated', workspace: renewed });
|
||||
const [leftStatus, rightStatus] = await Promise.all([
|
||||
request('network.capture.status', renewed.left.target),
|
||||
request('network.capture.status', renewed.right.target),
|
||||
runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.status', { workspaceId: renewed.id, side: 'left' },
|
||||
),
|
||||
runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.status', { workspaceId: renewed.id, side: 'right' },
|
||||
),
|
||||
]);
|
||||
dispatch({ type: 'capture.replace', capture: { left: leftStatus, right: rightStatus } });
|
||||
return renewed;
|
||||
@@ -677,10 +542,9 @@ export function AuthorizationTestingWorkspace({
|
||||
|
||||
const stopCapture = (side: BrowserAuthorizationSide) => run(async () => {
|
||||
if (!workspace) return;
|
||||
const status = await request('network.capture.stop', {
|
||||
tabId: workspace[side].target.tabId,
|
||||
frameId: workspace[side].target.frameId,
|
||||
});
|
||||
const status = await runBrowserAuthorizationTask<NetworkCaptureStatus>(
|
||||
'authorization.capture.stop', { workspaceId: workspace.id, side },
|
||||
);
|
||||
dispatch({ type: 'capture.update', side, status });
|
||||
}, `${side === 'left' ? leftLabel : rightLabel} 的请求捕获已停止`);
|
||||
|
||||
@@ -700,33 +564,30 @@ export function AuthorizationTestingWorkspace({
|
||||
const executionCopy = workspace?.execution
|
||||
? verdictCopy(workspace.execution.verdict, workspace.mode)
|
||||
: undefined;
|
||||
const incognitoAccessDenied = inspection?.browser === 'chromium'
|
||||
&& inspection.capabilities.incognitoAccess === 'denied';
|
||||
const firefoxContainerUnavailable = inspection?.browser === 'firefox'
|
||||
&& !inspection.capabilities.containerTabs;
|
||||
const identityStageReady = Boolean(
|
||||
leftTab && rightTab && sameOrigin && identityContextsSeparated && capabilityReady,
|
||||
leftInstance && rightInstance && leftDeviceId !== rightDeviceId
|
||||
&& leftTab && rightTab && sameOrigin && capabilityReady,
|
||||
);
|
||||
const prepareHint = !leftTab
|
||||
? '先选择当前登录页作为身份 A'
|
||||
: !rightTab
|
||||
? '还需要一个隔离登录的身份 B'
|
||||
const prepareHint = !instanceDiscoveryReady
|
||||
? '请更新并连接支持在线实例的 Yak 引擎'
|
||||
: !leftInstance
|
||||
? '当前 YTray 浏览器尚未被引擎识别'
|
||||
: !rightInstance
|
||||
? '请先用 YTray 启动并连接另一个浏览器实例'
|
||||
: !leftTab || !rightTab
|
||||
? '请在 A/B 浏览器中各打开一个 HTTP(S) 页面'
|
||||
: !sameOrigin
|
||||
? 'A/B 页面必须属于同一站点'
|
||||
: !leftIsolationContextId || !rightIsolationContextId
|
||||
? '正在确认两个页面的登录态边界'
|
||||
: !identityContextsSeparated
|
||||
? 'A/B 页面仍然共享同一登录态'
|
||||
: !capabilityReady
|
||||
? '请先连接支持授权测试的 Yak 引擎'
|
||||
: '两个身份页面已就绪';
|
||||
: !capabilityReady
|
||||
? '请先连接支持授权测试的 Yak 引擎'
|
||||
: '两个独立浏览器身份已就绪';
|
||||
|
||||
return <div className="section-view authorization-workspace">
|
||||
<div className="page-heading authorization-heading">
|
||||
<div>
|
||||
<span className="page-eyebrow">Browser-native authorization testing</span>
|
||||
<h1>授权测试工作区</h1>
|
||||
<p>从已经登录的两个页面建立身份隔离证明,录制双方正常请求,再由 Yak 生成并执行最小交叉矩阵。</p>
|
||||
<span className="page-eyebrow">确定性授权测试</span>
|
||||
<h1>越权测试</h1>
|
||||
<p>选择两个在线 YTray 浏览器的登录页,插件监测双方正常请求并执行最小交叉矩阵。</p>
|
||||
</div>
|
||||
<div className="authorization-heading-actions">
|
||||
<span className={`authorization-engine-state ${capabilityReady ? 'ready' : ''}`}>
|
||||
@@ -741,16 +602,6 @@ export function AuthorizationTestingWorkspace({
|
||||
{workspace && <Button variant="ghost" disabled={busy} onClick={() => void refreshWorkspace()}>
|
||||
<RefreshCw size={15} />复核状态
|
||||
</Button>}
|
||||
{workspace && bridge.capabilities?.includes('yakit.browser_authorization.open') && <Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void run(
|
||||
async () => { await request('authorization.yakit.open', { workspaceId: workspace.id }); },
|
||||
'已在 Yakit 打开完整证据工作区',
|
||||
)}
|
||||
>
|
||||
<ExternalLink size={15} />在 Yakit 深入分析
|
||||
</Button>}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void resetWorkspace()}>
|
||||
<RotateCcw size={15} />新建
|
||||
</Button>
|
||||
@@ -764,7 +615,7 @@ export function AuthorizationTestingWorkspace({
|
||||
|
||||
<div className="authorization-flow-strip" aria-label="授权测试步骤">
|
||||
{[
|
||||
['1', '身份与隔离', Boolean(workspace)],
|
||||
['1', 'A/B 浏览器', Boolean(workspace)],
|
||||
['2', '正常请求', Boolean(workspace?.baselines.left && workspace?.baselines.right)],
|
||||
['3', '确定性计划', Boolean(workspace?.plan)],
|
||||
['4', '结果证据', Boolean(workspace?.execution)],
|
||||
@@ -774,46 +625,6 @@ export function AuthorizationTestingWorkspace({
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{!workspace && <section className="authorization-cross-browser">
|
||||
<span><UserRoundPlus size={18} /></span>
|
||||
<div>
|
||||
<strong>选择独立浏览器实例</strong>
|
||||
<small>{!instanceDiscoveryReady
|
||||
? '当前 Yak 版本不能读取在线实例,请更新引擎后重连。'
|
||||
: browserInstances.length < 2
|
||||
? '只检测到当前浏览器,请先用 YTray 启动并连接另一个实例。'
|
||||
: '当前页面作为资源所有者,所选浏览器作为独立对照账号。'}</small>
|
||||
<div className="authorization-cross-browser__instances">
|
||||
{browserInstances.map((instance) => <button
|
||||
key={instance.deviceId}
|
||||
type="button"
|
||||
disabled={instance.current}
|
||||
aria-pressed={!instance.current && instance.deviceId === targetDeviceId}
|
||||
className={instance.current ? 'is-current' : instance.deviceId === targetDeviceId ? 'is-selected' : ''}
|
||||
onClick={() => setTargetDeviceId(instance.deviceId)}
|
||||
>
|
||||
<b>{instance.badge}</b>
|
||||
<span>{instance.current ? '当前' : instance.deviceId === targetDeviceId ? '已选择' : '在线'}</span>
|
||||
</button>)}
|
||||
<button
|
||||
type="button"
|
||||
className="is-refresh"
|
||||
aria-label="刷新在线浏览器实例"
|
||||
onClick={() => void refreshBrowserInstances().catch((error) => setLocalError(errorMessage(error)))}
|
||||
><RefreshCw size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void openCrossBrowserWorkspace()}
|
||||
>
|
||||
<ExternalLink size={15} />{targetBrowserInstance
|
||||
? `用 ${targetBrowserInstance.badge} 在 Yakit 测试`
|
||||
: '选择实例并在 Yakit 测试'}
|
||||
</Button>
|
||||
</section>}
|
||||
|
||||
{!workspace ? <section className="authorization-identity-stage">
|
||||
<div className="authorization-mode">
|
||||
<span>测试类型</span>
|
||||
@@ -831,9 +642,9 @@ export function AuthorizationTestingWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="authorization-identity-guide" aria-label="准备两个身份">
|
||||
<span className={leftTab ? 'complete' : 'current'}><b>{leftTab ? <Check size={12} /> : '1'}</b>当前登录页作为 A</span>
|
||||
<span className={leftTab ? 'complete' : 'current'}><b>{leftTab ? <Check size={12} /> : '1'}</b>浏览器 A 登录资源账号</span>
|
||||
<ArrowRight size={14} />
|
||||
<span className={rightTab ? 'complete' : leftTab ? 'current' : ''}><b>{rightTab ? <Check size={12} /> : '2'}</b>隔离页面登录 B</span>
|
||||
<span className={rightTab ? 'complete' : leftTab ? 'current' : ''}><b>{rightTab ? <Check size={12} /> : '2'}</b>浏览器 B 登录对照账号</span>
|
||||
<ArrowRight size={14} />
|
||||
<span className={identityStageReady ? 'complete' : ''}><b>{identityStageReady ? <Check size={12} /> : '3'}</b>验证并开始捕获</span>
|
||||
</div>
|
||||
@@ -844,66 +655,42 @@ export function AuthorizationTestingWorkspace({
|
||||
title={mode === 'vertical' ? '低权限身份' : '身份 A'}
|
||||
label={leftLabel}
|
||||
setLabel={(value) => dispatch({ type: 'patch', value: { leftLabel: value } })}
|
||||
instance={leftInstance}
|
||||
instances={browserInstances}
|
||||
tabId={leftTabId}
|
||||
setTabId={(value) => assignIdentityTab('left', value)}
|
||||
tabs={eligibleTabs}
|
||||
context={leftContext}
|
||||
disabledReason={(item) => authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: item.id,
|
||||
candidateIsolationContextId: contextForTab(inspection, item.id)?.contextId || item.isolationContextId,
|
||||
otherTabId: rightTabId,
|
||||
otherIsolationContextId: rightIsolationContextId,
|
||||
otherLabel: '身份 B',
|
||||
})}
|
||||
emptyHint="选择你现在已经登录的页面,作为基准身份 A"
|
||||
/>
|
||||
<div className="authorization-isolation-axis" aria-live="polite">
|
||||
<Fingerprint size={23} />
|
||||
<strong>{incognitoAccessDenied ? '需要无痕权限' : !leftTab ? '先准备身份 A' : !rightTab ? '再准备身份 B' : '浏览器隔离'}</strong>
|
||||
<span className={sameOrigin ? 'valid' : ''}>{sameOrigin ? '已是同一站点' : leftTab ? 'B 需打开同一站点' : '选择当前登录页'}</span>
|
||||
<span>{identityContextsSeparated ? '浏览上下文已分离' : rightTab ? '等待隔离验证' : 'A/B 不能共用登录态'}</span>
|
||||
{incognitoAccessDenied ? <div className="authorization-isolation-actions">
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void openIncognitoSettings()}>
|
||||
<ExternalLink size={14} />开启无痕权限
|
||||
</Button>
|
||||
<button type="button" disabled={busy} onClick={() => void recheckIsolationCapability()}>已开启,重新检测</button>
|
||||
</div> : <Button
|
||||
<strong>{leftInstance && rightInstance ? '独立浏览器隔离' : '等待两个在线实例'}</strong>
|
||||
<span className={sameOrigin ? 'valid' : ''}>{sameOrigin ? '已是同一站点' : leftTab ? 'B 需打开同一站点' : 'A/B 各选择登录页'}</span>
|
||||
<span>{leftInstance && rightInstance ? `${leftInstance.badge} / ${rightInstance.badge} 来自独立 Profile` : '请用 YTray 启动 A/B 浏览器'}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || !leftTab || !inspection || firefoxContainerUnavailable}
|
||||
onClick={() => void createIsolatedIdentity()}
|
||||
disabled={busy}
|
||||
onClick={() => void refreshBrowserInstances().catch((error) => setLocalError(errorMessage(error)))}
|
||||
>
|
||||
<UserRoundPlus size={14} />{!inspection
|
||||
? '正在检测隔离能力'
|
||||
: inspection.browser === 'firefox'
|
||||
? `${rightTab ? '重新创建' : '创建'} Container 身份 B`
|
||||
: `${rightTab ? '重新创建' : '创建'}无痕身份 B`}
|
||||
</Button>}
|
||||
<RefreshCw size={14} />刷新实例
|
||||
</Button>
|
||||
</div>
|
||||
<IdentitySlot
|
||||
side="B"
|
||||
title={mode === 'vertical' ? '高权限身份' : '身份 B'}
|
||||
label={rightLabel}
|
||||
setLabel={(value) => dispatch({ type: 'patch', value: { rightLabel: value } })}
|
||||
instance={rightInstance}
|
||||
instances={browserInstances}
|
||||
setInstanceId={assignRightInstance}
|
||||
tabId={rightTabId}
|
||||
setTabId={(value) => assignIdentityTab('right', value)}
|
||||
tabs={eligibleTabs}
|
||||
context={rightContext}
|
||||
disabledReason={(item) => authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: item.id,
|
||||
candidateIsolationContextId: contextForTab(inspection, item.id)?.contextId || item.isolationContextId,
|
||||
otherTabId: leftTabId,
|
||||
otherIsolationContextId: leftIsolationContextId,
|
||||
otherLabel: '身份 A',
|
||||
})}
|
||||
emptyHint={identityNotice || '在中间创建隔离页面,登录另一个账号后会自动选为身份 B'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="authorization-prepare-bar">
|
||||
<div>
|
||||
<LockKeyhole size={18} />
|
||||
<span><strong>原始 Cookie、Storage 与请求值不会进入界面</strong><small>Yak 只接收短时上下文句柄、字段指纹和用户选择的真实请求。</small></span>
|
||||
<span><strong>插件在 A/B 浏览器内完成代码级监测</strong><small>不调用 AI;Yak 只编排短时上下文、请求基线与确定性交叉矩阵。</small></span>
|
||||
</div>
|
||||
<div className="authorization-prepare-action">
|
||||
<small>{prepareHint}</small>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActiveTabInfo, BrowserIsolationContext } from '@/types/models';
|
||||
import type { ActiveTabInfo, BrowserAuthorizationInstance } from '@/types/models';
|
||||
|
||||
function shortPageAddress(tab: ActiveTabInfo): string {
|
||||
try {
|
||||
@@ -9,67 +9,59 @@ function shortPageAddress(tab: ActiveTabInfo): string {
|
||||
}
|
||||
}
|
||||
|
||||
function contextKindLabel(
|
||||
context: BrowserIsolationContext | undefined,
|
||||
selectedTab: ActiveTabInfo | undefined,
|
||||
): string {
|
||||
if (!selectedTab) return '等待选择页面';
|
||||
switch (context?.kind) {
|
||||
case 'chrome-incognito-store': return '无痕隔离上下文';
|
||||
case 'firefox-container':
|
||||
return context.containerName ? `Container · ${context.containerName}` : 'Container 隔离上下文';
|
||||
case 'managed-ephemeral-profile': return '独立浏览器 Profile';
|
||||
case 'verified-tab-local': return '标签页局部上下文';
|
||||
case 'sequential-auth-snapshot': return '顺序身份快照';
|
||||
default: return selectedTab.incognito ? '无痕浏览上下文' : '普通浏览上下文';
|
||||
}
|
||||
}
|
||||
|
||||
function windowKindLabel(tab: ActiveTabInfo): string {
|
||||
return tab.incognito ? '无痕窗口' : '普通窗口';
|
||||
}
|
||||
|
||||
export function IdentitySlot({
|
||||
side, title, label, setLabel, tabId, setTabId, tabs, context, disabledReason, emptyHint,
|
||||
side, title, label, setLabel, instance, instances, setInstanceId, tabId, setTabId,
|
||||
}: {
|
||||
side: 'A' | 'B';
|
||||
title: string;
|
||||
label: string;
|
||||
setLabel: (value: string) => void;
|
||||
instance?: BrowserAuthorizationInstance;
|
||||
instances: BrowserAuthorizationInstance[];
|
||||
setInstanceId?: (value: string) => void;
|
||||
tabId?: number;
|
||||
setTabId: (value: number | undefined) => void;
|
||||
tabs: ActiveTabInfo[];
|
||||
context?: BrowserIsolationContext;
|
||||
disabledReason: (tab: ActiveTabInfo) => string | undefined;
|
||||
emptyHint: string;
|
||||
}) {
|
||||
const selectedTab = tabs.find((item) => item.id === tabId);
|
||||
const selectedTab = instance?.tabs.find((item) => item.id === tabId);
|
||||
return <div className={`authorization-identity-slot ${selectedTab ? 'is-selected' : 'is-empty'}`}>
|
||||
<header><span>{side}</span><div><strong>{title}</strong><small>{contextKindLabel(context, selectedTab)}</small></div></header>
|
||||
<label><span>账号备注</span><input value={label} maxLength={80} onChange={(event) => setLabel(event.target.value)} placeholder={side === 'A' ? '例如:普通用户' : '例如:另一个用户'} /></label>
|
||||
<label><span>{side === 'A' ? '当前已登录页面' : '另一个已登录页面'}</span><select
|
||||
aria-label={`身份 ${side} 的已登录页面`}
|
||||
value={selectedTab?.id || ''}
|
||||
onChange={(event) => setTabId(event.target.value ? Number(event.target.value) : undefined)}
|
||||
>
|
||||
<option value="">{side === 'A' ? '选择当前登录页面' : '选择页面,或在中间创建隔离身份'}</option>
|
||||
{tabs.map((item) => {
|
||||
const reason = disabledReason(item);
|
||||
return <option value={item.id} key={item.id} disabled={Boolean(reason)}>
|
||||
{item.title} · {shortPageAddress(item)} · {windowKindLabel(item)}{reason ? ` · ${reason}` : ''}
|
||||
</option>;
|
||||
})}
|
||||
</select></label>
|
||||
<header>
|
||||
<span>{instance?.badge || side}</span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<small>{instance ? `YTray 浏览器 ${instance.badge} · 在线` : '等待在线浏览器'}</small>
|
||||
</div>
|
||||
</header>
|
||||
<label>
|
||||
<span>账号备注</span>
|
||||
<input value={label} maxLength={80} onChange={(event) => setLabel(event.target.value)} placeholder={side === 'A' ? '例如:资源所有者' : '例如:对照账号'} />
|
||||
</label>
|
||||
{setInstanceId && <label>
|
||||
<span>浏览器实例</span>
|
||||
<select aria-label={`身份 ${side} 的浏览器实例`} value={instance?.deviceId || ''} onChange={(event) => setInstanceId(event.target.value)}>
|
||||
<option value="">选择另一个在线实例</option>
|
||||
{instances.filter((item) => !item.current).map((item) => <option value={item.deviceId} key={item.deviceId}>
|
||||
浏览器 {item.badge} · {item.tabs.length} 个页面
|
||||
</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<label>
|
||||
<span>已登录页面</span>
|
||||
<select
|
||||
aria-label={`身份 ${side} 的已登录页面`}
|
||||
value={selectedTab?.id || ''}
|
||||
disabled={!instance}
|
||||
onChange={(event) => setTabId(event.target.value ? Number(event.target.value) : undefined)}
|
||||
>
|
||||
<option value="">{instance ? '选择 HTTP(S) 页面' : '先选择浏览器实例'}</option>
|
||||
{instance?.tabs.map((item) => <option value={item.id} key={item.id}>
|
||||
{item.title || '未命名页面'} · {shortPageAddress(item)}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="authorization-identity-meta">
|
||||
<span><i className={context?.level || ''} />{selectedTab
|
||||
? context?.level === 'strong'
|
||||
? '强隔离上下文'
|
||||
: context?.level === 'conditional'
|
||||
? '条件隔离上下文'
|
||||
: '隔离待验证'
|
||||
: '尚未选择页面'}</span>
|
||||
<code title={selectedTab?.url || emptyHint}>
|
||||
{selectedTab ? `${windowKindLabel(selectedTab)} · ${selectedTab.url}` : emptyHint}
|
||||
<span><i className={instance ? 'strong' : ''} />{instance ? '独立浏览器 Profile' : '尚未选择实例'}</span>
|
||||
<code title={selectedTab?.url || instance?.error || ''}>
|
||||
{selectedTab?.url || instance?.error || '请先在该浏览器打开并登录目标站点'}
|
||||
</code>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
@@ -116,105 +116,6 @@
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.authorization-cross-browser {
|
||||
min-height: 64px;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--authorization-accent) 24%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--authorization-accent) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.authorization-cross-browser > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--authorization-accent) 12%, var(--surface));
|
||||
color: var(--authorization-accent);
|
||||
}
|
||||
|
||||
.authorization-cross-browser strong,
|
||||
.authorization-cross-browser small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.authorization-cross-browser > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.authorization-cross-browser strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.authorization-cross-browser small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button {
|
||||
height: 27px;
|
||||
padding: 0 8px 0 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--muted-strong);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button > b {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button.is-current {
|
||||
opacity: 1;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button.is-selected {
|
||||
border-color: color-mix(in srgb, var(--authorization-accent) 50%, var(--border));
|
||||
background: color-mix(in srgb, var(--authorization-accent) 9%, var(--surface));
|
||||
color: var(--authorization-accent);
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button.is-selected > b {
|
||||
background: var(--authorization-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.authorization-cross-browser__instances > button.is-refresh {
|
||||
width: 27px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.authorization-identity-stage,
|
||||
.authorization-baseline-stage,
|
||||
.authorization-plan-stage {
|
||||
@@ -484,25 +385,6 @@
|
||||
margin: 7px 0 8px;
|
||||
}
|
||||
|
||||
.authorization-isolation-actions {
|
||||
margin: 7px 0 8px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.authorization-isolation-actions .ui-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.authorization-isolation-actions > button:not(.ui-button) {
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.authorization-isolation-actions > button:not(.ui-button):disabled {
|
||||
color: var(--muted);
|
||||
@@ -1557,15 +1439,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.authorization-cross-browser {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.authorization-cross-browser > .ui-button {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.authorization-heading,
|
||||
.authorization-prepare-bar,
|
||||
.authorization-section-heading {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
authorizationIdentityOptionDisabledReason,
|
||||
normalizeAuthorizationIdentityTabSelection,
|
||||
} from './identity-selection';
|
||||
|
||||
describe('normalizeAuthorizationIdentityTabSelection', () => {
|
||||
it('moves the only surviving persisted page to identity A', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [22],
|
||||
activeTabId: 22,
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 22,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale selections without visually falling back to another page', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [],
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: undefined,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps two different valid user selections', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 22,
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the active page for A while preserving a different B page', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 11,
|
||||
leftTabId: 99,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not automatically treat a second ordinary tab as identity B', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 11,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizationIdentityOptionDisabledReason', () => {
|
||||
it('disables the exact page already assigned to the other identity', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 11,
|
||||
candidateIsolationContextId: 'profile:normal',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBe('已用于身份 A');
|
||||
});
|
||||
|
||||
it('disables another page that shares the other identity login context', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 22,
|
||||
candidateIsolationContextId: 'profile:normal',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBe('与身份 A 共享登录态');
|
||||
});
|
||||
|
||||
it('keeps pages from another isolation context selectable', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 22,
|
||||
candidateIsolationContextId: 'profile:incognito',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
export interface AuthorizationIdentityTabSelection {
|
||||
leftTabId?: number;
|
||||
rightTabId?: number;
|
||||
}
|
||||
|
||||
export interface NormalizeAuthorizationIdentityTabSelectionInput
|
||||
extends AuthorizationIdentityTabSelection {
|
||||
eligibleTabIds: readonly number[];
|
||||
activeTabId?: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationIdentityOptionConflictInput {
|
||||
candidateTabId: number;
|
||||
candidateIsolationContextId?: string;
|
||||
otherTabId?: number;
|
||||
otherIsolationContextId?: string;
|
||||
otherLabel: string;
|
||||
}
|
||||
|
||||
export function authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId,
|
||||
candidateIsolationContextId,
|
||||
otherTabId,
|
||||
otherIsolationContextId,
|
||||
otherLabel,
|
||||
}: AuthorizationIdentityOptionConflictInput): string | undefined {
|
||||
if (otherTabId !== undefined && candidateTabId === otherTabId) {
|
||||
return `已用于${otherLabel}`;
|
||||
}
|
||||
if (
|
||||
candidateIsolationContextId
|
||||
&& otherIsolationContextId
|
||||
&& candidateIsolationContextId === otherIsolationContextId
|
||||
) {
|
||||
return `与${otherLabel} 共享登录态`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds,
|
||||
activeTabId,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
}: NormalizeAuthorizationIdentityTabSelectionInput): AuthorizationIdentityTabSelection {
|
||||
const available = new Set(
|
||||
eligibleTabIds.filter((tabId) => Number.isSafeInteger(tabId) && tabId > 0),
|
||||
);
|
||||
const existing = (tabId?: number): number | undefined => (
|
||||
tabId !== undefined && available.has(tabId) ? tabId : undefined
|
||||
);
|
||||
|
||||
let left = existing(leftTabId);
|
||||
let right = existing(rightTabId);
|
||||
|
||||
if (left !== undefined && left === right) right = undefined;
|
||||
|
||||
if (left === undefined) {
|
||||
left = existing(activeTabId) ?? right ?? eligibleTabIds.find((tabId) => available.has(tabId));
|
||||
if (left === right) right = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
leftTabId: left,
|
||||
rightTabId: right,
|
||||
};
|
||||
}
|
||||
@@ -16,12 +16,14 @@ function fixtureWorkspace(): BrowserAuthorizationWorkspace {
|
||||
mode: 'horizontal',
|
||||
state: 'ready',
|
||||
left: {
|
||||
deviceId: 'device-a',
|
||||
accountLabel: '账号 A',
|
||||
origin: 'https://example.test',
|
||||
target: { tabId: 11, frameId: 0, documentId: 'document-a' },
|
||||
authentication: { status: 'authenticated', cookieCount: 1, storageEntryCount: 0 },
|
||||
},
|
||||
right: {
|
||||
deviceId: 'device-b',
|
||||
accountLabel: '账号 B',
|
||||
origin: 'https://example.test',
|
||||
target: { tabId: 22, frameId: 0, documentId: 'document-b' },
|
||||
@@ -71,6 +73,8 @@ describe('authorization workspace UI reducer', () => {
|
||||
it('resets workflow evidence without discarding the selected identities', () => {
|
||||
const previous = {
|
||||
...INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
leftDeviceId: 'device-a',
|
||||
rightDeviceId: 'device-b',
|
||||
leftTabId: 11,
|
||||
rightTabId: 12,
|
||||
workspace: { id: 'old' } as BrowserAuthorizationWorkspace,
|
||||
@@ -80,6 +84,8 @@ describe('authorization workspace UI reducer', () => {
|
||||
|
||||
expect(next.leftTabId).toBe(11);
|
||||
expect(next.rightTabId).toBe(12);
|
||||
expect(next.leftDeviceId).toBe('device-a');
|
||||
expect(next.rightDeviceId).toBe('device-b');
|
||||
expect(next.workspace).toBeUndefined();
|
||||
expect(next.capture).toEqual({});
|
||||
});
|
||||
@@ -87,10 +93,8 @@ describe('authorization workspace UI reducer', () => {
|
||||
it('persists only durable workflow state', () => {
|
||||
const value = persistedAuthorizationWorkspaceUI({
|
||||
...INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
inspection: { version: 1 } as never,
|
||||
capture: { left: { active: true } } as never,
|
||||
});
|
||||
expect(value).not.toHaveProperty('inspection');
|
||||
expect(value).not.toHaveProperty('capture');
|
||||
});
|
||||
|
||||
@@ -99,6 +103,8 @@ describe('authorization workspace UI reducer', () => {
|
||||
type: 'hydrate',
|
||||
value: {
|
||||
mode: 'vertical',
|
||||
leftDeviceId: 'device-a',
|
||||
rightDeviceId: 'device-b',
|
||||
leftTabId: 11,
|
||||
rightTabId: 'not-a-tab',
|
||||
leftLabel: '低权限账号',
|
||||
@@ -126,6 +132,8 @@ describe('authorization workspace UI reducer', () => {
|
||||
};
|
||||
const normalized = normalizePersistedAuthorizationWorkspaceUI({
|
||||
mode: 'horizontal',
|
||||
leftDeviceId: 'device-a',
|
||||
rightDeviceId: 'device-b',
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
leftLabel: '账号 A',
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
BrowserIsolationInspection,
|
||||
NetworkCaptureStatus,
|
||||
} from '@/types/models';
|
||||
import type { NetworkCaptureStatus } from '@/types/models';
|
||||
import type {
|
||||
BrowserAuthorizationBaselineCandidate,
|
||||
BrowserAuthorizationMode,
|
||||
@@ -19,6 +16,8 @@ const EMPTY_SELECTION: Record<BrowserAuthorizationSide, string> = { left: '', ri
|
||||
|
||||
export interface PersistedAuthorizationWorkspaceUI {
|
||||
mode: BrowserAuthorizationMode;
|
||||
leftDeviceId: string;
|
||||
rightDeviceId: string;
|
||||
leftTabId?: number;
|
||||
rightTabId?: number;
|
||||
leftLabel: string;
|
||||
@@ -31,12 +30,13 @@ export interface PersistedAuthorizationWorkspaceUI {
|
||||
}
|
||||
|
||||
export interface AuthorizationWorkspaceUIState extends PersistedAuthorizationWorkspaceUI {
|
||||
inspection?: BrowserIsolationInspection;
|
||||
capture: Partial<Record<BrowserAuthorizationSide, NetworkCaptureStatus>>;
|
||||
}
|
||||
|
||||
export const INITIAL_AUTHORIZATION_WORKSPACE_UI: AuthorizationWorkspaceUIState = {
|
||||
mode: 'horizontal',
|
||||
leftDeviceId: '',
|
||||
rightDeviceId: '',
|
||||
leftLabel: '账号 A',
|
||||
rightLabel: '账号 B',
|
||||
candidates: EMPTY_AUTHORIZATION_CANDIDATES,
|
||||
@@ -124,6 +124,7 @@ function safeWorkspaceForUI(input: unknown): BrowserAuthorizationWorkspace | und
|
||||
const target = record(side?.target);
|
||||
const authentication = record(side?.authentication);
|
||||
return Boolean(side && target && authentication
|
||||
&& typeof side.deviceId === 'string' && side.deviceId
|
||||
&& Number.isSafeInteger(target.tabId) && Number(target.tabId) > 0
|
||||
&& Number.isSafeInteger(target.frameId) && Number(target.frameId) >= 0
|
||||
&& typeof target.documentId === 'string' && target.documentId
|
||||
@@ -265,6 +266,8 @@ export function normalizePersistedAuthorizationWorkspaceUI(
|
||||
};
|
||||
return {
|
||||
mode: value.mode === 'vertical' ? 'vertical' : 'horizontal',
|
||||
leftDeviceId: typeof value.leftDeviceId === 'string' ? value.leftDeviceId.slice(0, 320) : '',
|
||||
rightDeviceId: typeof value.rightDeviceId === 'string' ? value.rightDeviceId.slice(0, 320) : '',
|
||||
leftTabId: Number.isSafeInteger(value.leftTabId) && Number(value.leftTabId) > 0 ? Number(value.leftTabId) : undefined,
|
||||
rightTabId: Number.isSafeInteger(value.rightTabId) && Number(value.rightTabId) > 0 ? Number(value.rightTabId) : undefined,
|
||||
leftLabel: typeof value.leftLabel === 'string' ? value.leftLabel.slice(0, 80) : '账号 A',
|
||||
@@ -290,6 +293,8 @@ export function authorizationWorkspaceUIReducer(
|
||||
return {
|
||||
...state,
|
||||
mode: value.mode === 'vertical' ? 'vertical' : 'horizontal',
|
||||
leftDeviceId: value.leftDeviceId || '',
|
||||
rightDeviceId: value.rightDeviceId || '',
|
||||
leftTabId: value.leftTabId,
|
||||
rightTabId: value.rightTabId,
|
||||
leftLabel: value.leftLabel || '账号 A',
|
||||
@@ -351,6 +356,8 @@ export function persistedAuthorizationWorkspaceUI(
|
||||
): PersistedAuthorizationWorkspaceUI {
|
||||
return {
|
||||
mode: state.mode,
|
||||
leftDeviceId: state.leftDeviceId,
|
||||
rightDeviceId: state.rightDeviceId,
|
||||
leftTabId: state.leftTabId,
|
||||
rightTabId: state.rightTabId,
|
||||
leftLabel: state.leftLabel,
|
||||
|
||||
@@ -95,14 +95,6 @@ describe('extension request schemas', () => {
|
||||
action: 'authorization.engine.task',
|
||||
payload: { schema: 'authorization.unknown', payload: {} },
|
||||
})).toThrow('schema');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'authorization.yakit.open',
|
||||
payload: { workspaceId: 'authorization-workspace-1' },
|
||||
}).action).toBe('authorization.yakit.open');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'authorization.yakit.open',
|
||||
payload: { tabId: 12, mode: 'horizontal', targetDeviceId: 'device-b' },
|
||||
}).payload).toEqual({ tabId: 12, mode: 'horizontal', targetDeviceId: 'device-b' });
|
||||
expect(parseExtensionRequest({ action: 'authorization.yakit.instances' }).action)
|
||||
.toBe('authorization.yakit.instances');
|
||||
expect(parseExtensionRequest({
|
||||
|
||||
@@ -289,6 +289,9 @@ const payloadSchemas = {
|
||||
schema: v.picklist([
|
||||
'authorization.workspace.create',
|
||||
'authorization.workspace.inspect',
|
||||
'authorization.capture.start',
|
||||
'authorization.capture.status',
|
||||
'authorization.capture.stop',
|
||||
'authorization.baseline.candidates',
|
||||
'authorization.baseline.bind',
|
||||
'authorization.logical.bind',
|
||||
@@ -302,14 +305,6 @@ const payloadSchemas = {
|
||||
payload: v.record(v.string(), v.unknown()),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(5_000), v.maxValue(120_000))),
|
||||
}),
|
||||
'authorization.yakit.open': v.union([
|
||||
v.strictObject({ workspaceId: id }),
|
||||
v.strictObject({
|
||||
tabId,
|
||||
mode: v.optional(v.picklist(['horizontal', 'vertical'])),
|
||||
targetDeviceId: v.optional(id),
|
||||
}),
|
||||
]),
|
||||
'authorization.yakit.instances': noPayload,
|
||||
'proxy.save': proxyProfile,
|
||||
'proxy.delete': v.strictObject({ id }),
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface ExtensionRequestMap {
|
||||
schema:
|
||||
| 'authorization.workspace.create'
|
||||
| 'authorization.workspace.inspect'
|
||||
| 'authorization.capture.start'
|
||||
| 'authorization.capture.status'
|
||||
| 'authorization.capture.stop'
|
||||
| 'authorization.baseline.candidates'
|
||||
| 'authorization.baseline.bind'
|
||||
| 'authorization.logical.bind'
|
||||
@@ -97,14 +100,6 @@ export interface ExtensionRequestMap {
|
||||
};
|
||||
output: unknown;
|
||||
};
|
||||
'authorization.yakit.open': {
|
||||
input: { workspaceId: string } | {
|
||||
tabId: number;
|
||||
mode?: 'horizontal' | 'vertical';
|
||||
targetDeviceId?: string;
|
||||
};
|
||||
output: { workspaceId?: string; tabId?: number; opened: boolean };
|
||||
};
|
||||
'authorization.yakit.instances': {
|
||||
input: undefined;
|
||||
output: { instances: BrowserAuthorizationInstance[] };
|
||||
|
||||
@@ -222,6 +222,8 @@ export interface BrowserAuthorizationInstance {
|
||||
deviceId: string;
|
||||
badge: string;
|
||||
current: boolean;
|
||||
tabs: ActiveTabInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type BridgePairingState = 'idle' | 'requesting' | 'pending' | 'approved' | 'rejected' | 'expired' | 'error';
|
||||
|
||||
Reference in New Issue
Block a user