feat(authorization): test across ytray browser instances

This commit is contained in:
go0p
2026-09-04 11:27:53 +08:00
parent 951ca046ab
commit 1f703dfbdc
15 changed files with 205 additions and 715 deletions
-5
View File
@@ -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': {
+1 -1
View File
@@ -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,
-8
View File
@@ -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({
+3 -8
View File
@@ -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 }),
+3 -8
View File
@@ -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[] };
+2
View File
@@ -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';