diff --git a/src/app/background/index.ts b/src/app/background/index.ts index 9f6dd27..bf308f9 100644 --- a/src/app/background/index.ts +++ b/src/app/background/index.ts @@ -169,6 +169,8 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. 'yakit.browser_authorization.open', request.payload, )); + case 'authorization.yakit.instances': + return ok(await engineBridge.requestEngine('yakit.browser_authorization.instances', {})); case 'context.capture': { const { tabId, frameId, documentId, ...options } = request.payload; const target = await requiredRequestTarget({ tabId, frameId, documentId }, sender); diff --git a/src/features/authorization-testing/ui/AuthorizationTestingWorkspace.tsx b/src/features/authorization-testing/ui/AuthorizationTestingWorkspace.tsx index 755ef61..95ab0a7 100644 --- a/src/features/authorization-testing/ui/AuthorizationTestingWorkspace.tsx +++ b/src/features/authorization-testing/ui/AuthorizationTestingWorkspace.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { authorizationShareGrantInput } from '@/features/grants/gateway-share'; import { errorMessage, request } from '@/platform/messaging/runtime'; import type { - ActiveTabInfo, BridgeStatus, BrowserIsolationContext, BrowserIsolationInspection, + ActiveTabInfo, BridgeStatus, BrowserAuthorizationInstance, BrowserIsolationContext, BrowserIsolationInspection, ExtensionState, NetworkCaptureStatus, } from '@/types/models'; import { @@ -247,6 +247,8 @@ export function AuthorizationTestingWorkspace({ const [localError, setLocalError] = useState(''); const [identityNotice, setIdentityNotice] = useState(''); const [clock, setClock] = useState(Date.now()); + const [browserInstances, setBrowserInstances] = useState([]); + const [targetDeviceId, setTargetDeviceId] = useState(''); const leftTab = eligibleTabs.find((item) => item.id === leftTabId); const rightTab = eligibleTabs.find((item) => item.id === rightTabId); @@ -262,6 +264,10 @@ export function AuthorizationTestingWorkspace({ 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 currentBrowserInstance = browserInstances.find((instance) => instance.current); + const targetBrowserInstance = browserInstances.find((instance) => instance.deviceId === targetDeviceId); const refreshInspection = useCallback(async () => { const next = await request('isolation.inspect', { @@ -270,6 +276,21 @@ export function AuthorizationTestingWorkspace({ dispatch({ type: 'patch', value: { inspection: next } }); }, [eligibleTabs]); + const refreshBrowserInstances = useCallback(async () => { + if (!instanceDiscoveryReady) { + setBrowserInstances([]); + setTargetDeviceId(''); + return; + } + const result = await request('authorization.yakit.instances'); + setBrowserInstances(result.instances); + setTargetDeviceId((current) => ( + result.instances.some((instance) => !instance.current && instance.deviceId === current) + ? current + : result.instances.find((instance) => !instance.current)?.deviceId || '' + )); + }, [instanceDiscoveryReady]); + useEffect(() => { void (async () => { try { @@ -315,6 +336,17 @@ export function AuthorizationTestingWorkspace({ void refreshInspection().catch((error) => setLocalError(errorMessage(error))); }, [refreshInspection]); + useEffect(() => { + void refreshBrowserInstances().catch((error) => setLocalError(errorMessage(error))); + const refreshWhenVisible = () => { + if (document.visibilityState === 'visible') { + void refreshBrowserInstances().catch((error) => setLocalError(errorMessage(error))); + } + }; + document.addEventListener('visibilitychange', refreshWhenVisible); + return () => document.removeEventListener('visibilitychange', refreshWhenVisible); + }, [refreshBrowserInstances]); + useEffect(() => { if (!hydrated || workspace || !leftTab || !rightTab) return; const reason = authorizationIdentityOptionDisabledReason({ @@ -447,11 +479,16 @@ export function AuthorizationTestingWorkspace({ const openCrossBrowserWorkspace = () => run(async () => { if (!activeTab) throw new Error('请先选择需要测试的页面'); + if (!targetBrowserInstance) throw new Error('请选择另一个在线的 YTray 浏览器实例'); if (!bridge.capabilities?.includes('yakit.browser_authorization.open')) { throw new Error('当前 Yak 引擎不支持打开跨浏览器工作区'); } - await request('authorization.yakit.open', { tabId: activeTab.id, mode }); - }, '已在 Yakit 打开跨浏览器越权测试,并带入当前页面'); + await request('authorization.yakit.open', { + tabId: activeTab.id, + mode, + targetDeviceId: targetBrowserInstance.deviceId, + }); + }, `已将浏览器 ${currentBrowserInstance?.badge || '当前'} 与 ${targetBrowserInstance?.badge || '对照'} 带入 Yakit`); const refreshWorkspaceDocuments = async (): Promise => { if (!workspace || !leftTab || !rightTab) throw new Error('请先建立 A/B 工作区'); @@ -733,15 +770,38 @@ export function AuthorizationTestingWorkspace({ {!workspace &&
- 使用 YTray 的多个浏览器实例 - 把当前页面作为资源身份带入 Yakit,再选择在线的浏览器 A、B 或 C 作为独立对照账号。 + 选择独立浏览器实例 + {!instanceDiscoveryReady + ? '当前 Yak 版本不能读取在线实例,请更新引擎后重连。' + : browserInstances.length < 2 + ? '只检测到当前浏览器,请先用 YTray 启动并连接另一个实例。' + : '当前页面作为资源所有者,所选浏览器作为独立对照账号。'} +
+ {browserInstances.map((instance) => )} + +
} diff --git a/src/features/authorization-testing/ui/authorization-testing-workspace.css b/src/features/authorization-testing/ui/authorization-testing-workspace.css index 0eb08aa..c360971 100644 --- a/src/features/authorization-testing/ui/authorization-testing-workspace.css +++ b/src/features/authorization-testing/ui/authorization-testing-workspace.css @@ -143,6 +143,10 @@ display: block; } +.authorization-cross-browser > div { + min-width: 0; +} + .authorization-cross-browser strong { font-size: var(--text-sm); } @@ -154,6 +158,63 @@ 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 { diff --git a/src/protocol/extension.test.ts b/src/protocol/extension.test.ts index 24a6049..df51e46 100644 --- a/src/protocol/extension.test.ts +++ b/src/protocol/extension.test.ts @@ -101,8 +101,10 @@ describe('extension request schemas', () => { }).action).toBe('authorization.yakit.open'); expect(parseExtensionRequest({ action: 'authorization.yakit.open', - payload: { tabId: 12, mode: 'horizontal' }, - }).payload).toEqual({ tabId: 12, mode: 'horizontal' }); + 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({ action: 'network.capture.start', payload: { diff --git a/src/protocol/extension.ts b/src/protocol/extension.ts index b765abf..2ad56ec 100644 --- a/src/protocol/extension.ts +++ b/src/protocol/extension.ts @@ -307,8 +307,10 @@ const payloadSchemas = { 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 }), 'proxy.switch': v.strictObject({ id }), diff --git a/src/types/messages.ts b/src/types/messages.ts index 6ac1053..72ab300 100644 --- a/src/types/messages.ts +++ b/src/types/messages.ts @@ -14,6 +14,7 @@ import type { BrowserFirefoxManagedContainer, BrowserIsolationInspection, BrowserIsolationProof, + BrowserAuthorizationInstance, BrowserDeepCaptureMatcher, BrowserDeepCaptureStatus, CookieInput, @@ -97,9 +98,17 @@ export interface ExtensionRequestMap { output: unknown; }; 'authorization.yakit.open': { - input: { workspaceId: string } | { tabId: number; mode?: 'horizontal' | 'vertical' }; + 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[] }; + }; 'proxy.save': { input: ProxyProfile; output: ExtensionState }; 'proxy.delete': { input: { id: string }; output: ExtensionState }; 'proxy.switch': { input: { id: string }; output: ExtensionState }; diff --git a/src/types/models.ts b/src/types/models.ts index c3011ce..744e28a 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -218,6 +218,12 @@ export interface BridgePairedEngine { pairedAt: number; } +export interface BrowserAuthorizationInstance { + deviceId: string; + badge: string; + current: boolean; +} + export type BridgePairingState = 'idle' | 'requesting' | 'pending' | 'approved' | 'rejected' | 'expired' | 'error'; export interface BridgePairingStatus {