import { useCallback, useEffect, useMemo, useState } from 'react'; import { browser } from 'wxt/browser'; import { Activity, AlertTriangle, ArrowDown, Braces, Check, ChevronRight, CircleStop, Copy, Fingerprint, Globe2, Bug, FileKey2, KeyRound, Link2, Navigation, Play, Radio, RefreshCw, Save, ShieldCheck, Sparkles, Trash2, Webhook, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { errorMessage, request } from '@/platform/messaging/runtime'; import type { ActiveTabInfo, BrowserPageCallable, BrowserPageCallableExecution, BrowserRecordingEvent, BrowserProfileInferenceCandidate, BrowserRecordingArgumentRole, BrowserRecordingSnapshot, } from '@/types/models'; import type { CapturedCallableSample } from '@/features/deep-capture/callable-sample'; import { DeepCaptureWorkspace } from '@/features/deep-capture/DeepCaptureWorkspace'; import { cryptoEventLabel } from '@/features/browser-crypto/model'; import { cryptoAdapterLabel } from '@/features/browser-crypto/adapters/catalog'; import { BrowserTransformWorkspace, type BrowserTransformSuggestionSeed, } from '@/features/browser-transform/BrowserTransformWorkspace'; import { createBrowserTransformProfileInput } from '@/features/browser-transform/profile-draft'; type RunTask = (task: () => Promise, success?: string) => Promise; const CHROMIUM_CONTEXT_TOOLS = !import.meta.env.FIREFOX; interface RecordingWorkspaceProps { tab?: ActiveTabInfo; busy: boolean; run: RunTask; } const KIND_LABELS: Record = { interaction: '页面操作', fetch: 'Fetch', xhr: 'XHR', form: '表单', beacon: 'Beacon', worker: 'Worker', message: '消息通道', websocket: 'WebSocket', crypto: '密码调用', transform: '数据转换', navigation: '浏览器导航', }; const ARGUMENT_LABELS: Record = { data: '明文输入', key: 'Key', iv: 'IV', algorithm: '算法', options: '选项', signature: '签名', salt: 'Salt', nonce: 'Nonce', aad: 'AAD', unknown: '参数', }; function confidenceLabel(candidate: BrowserProfileInferenceCandidate): string { const level = candidate.confidence.level === 'high' ? '高' : candidate.confidence.level === 'medium' ? '中' : '低'; return `${level}置信度 · ${candidate.confidence.score}`; } function eventIcon(kind: BrowserRecordingEvent['kind']) { if (kind === 'navigation') return ; if (kind === 'interaction') return ; if (kind === 'crypto') return ; if (kind === 'websocket' || kind === 'worker' || kind === 'message') return ; if (kind === 'fetch' || kind === 'xhr' || kind === 'form' || kind === 'beacon') return ; return ; } function requestPath(url?: string): string { if (!url) return ''; try { return new URL(url, 'https://recording.invalid').pathname; } catch { return url; } } function eventTitle(event: BrowserRecordingEvent): string { if (event.kind === 'navigation') return event.label || '页面跳转'; if (event.kind === 'interaction') return event.label || event.operation; if (event.kind === 'fetch' || event.kind === 'xhr' || event.kind === 'form' || event.kind === 'beacon') { return `${event.method || 'GET'} ${requestPath(event.url) || '/'}`; } return event.kind === 'crypto' ? cryptoEventLabel(event) : event.operation; } function eventSubtitle(event: BrowserRecordingEvent): string { if (event.kind === 'navigation') { const from = requestPath(event.navigation?.fromUrl); const to = requestPath(event.navigation?.toUrl || event.url); return from && to ? `${from} → ${to}` : to || '文档边界'; } if (event.kind === 'fetch' || event.kind === 'xhr' || event.kind === 'form' || event.kind === 'beacon') { try { return event.url ? new URL(event.url, 'https://recording.invalid').host : KIND_LABELS[event.kind]; } catch { return KIND_LABELS[event.kind]; } } if (event.kind === 'crypto' && event.crypto) { const keyLabel = event.crypto.key ? `${event.crypto.key.kind === 'public' ? '公钥' : event.crypto.key.kind === 'private' ? '私钥' : event.crypto.key.kind === 'secret' ? '对称密钥' : '密钥'}${event.crypto.key.bits ? ` ${event.crypto.key.bits} bit` : ''}` : undefined; const details = [cryptoAdapterLabel(event.crypto.adapterId), event.crypto.mode, keyLabel, event.crypto.padding, event.crypto.outputEncoding] .filter(Boolean).join(' · '); return details || event.scriptUrl || KIND_LABELS[event.kind]; } if (event.kind === 'worker' || event.kind === 'message') { return [event.direction === 'send' ? '发送' : event.direction === 'receive' ? '接收' : undefined, event.channelId?.slice(-12), event.dataType] .filter(Boolean).join(' · ') || KIND_LABELS[event.kind]; } return event.scriptUrl || event.dataType || KIND_LABELS[event.kind]; } function relativeTime(timestamp: number, startedAt?: number): string { if (!startedAt) return ''; const elapsed = Math.max(0, timestamp - startedAt); if (elapsed < 1_000) return `+${Math.round(elapsed)} ms`; if (elapsed < 60_000) return `+${(elapsed / 1_000).toFixed(elapsed < 10_000 ? 2 : 1)} s`; return `+${Math.floor(elapsed / 60_000)}m ${Math.round((elapsed % 60_000) / 1_000)}s`; } function durationLabel(startedAt: number, endedAt: number): string { const duration = Math.max(0, endedAt - startedAt); if (duration < 1_000) return `${Math.round(duration)} ms`; return `${(duration / 1_000).toFixed(duration < 10_000 ? 2 : 1)} s`; } function navigationPhaseLabel(event: BrowserRecordingEvent): string { const phase = event.navigation?.phase; if (phase === 'started') return '正在切换页面'; if (phase === 'committed') return '新文档已提交'; if (phase === 'completed') return '新页面已就绪'; if (phase === 'restored') return '旧页面现场已恢复'; if (phase === 'same-document') return '当前文档保持可用'; if (phase === 'failed') return '跳转失败'; return '浏览器文档边界'; } function emptySnapshot(tabId: number): BrowserRecordingSnapshot { return { status: { active: false, target: { tabId, frameId: 0 }, documentAvailable: true, count: 0, droppedCount: 0 }, events: [], traces: [], links: [], callables: [], profileCandidates: [], }; } function shortSample(event?: BrowserRecordingEvent): string | undefined { const value = event?.inputPreview || event?.inputs.find((item) => item.preview)?.preview; return value?.trim() || undefined; } function eventAvailableInDocument( event: BrowserRecordingEvent | undefined, currentDocumentId: string | undefined, documentAvailable: boolean, ): boolean { return documentAvailable && Boolean(event) && ( !event?.documentId || !currentDocumentId || event.documentId === currentDocumentId ); } export function RecordingWorkspace({ tab, busy, run }: RecordingWorkspaceProps) { const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording'); const [autoArmRequest, setAutoArmRequest] = useState(0); const [deepPaused, setDeepPaused] = useState(false); const [snapshot, setSnapshot] = useState(); const [captureValues, setCaptureValues] = useState(false); const [selectedTraceId, setSelectedTraceId] = useState(''); const [selectedEventId, setSelectedEventId] = useState(''); const [loadError, setLoadError] = useState(''); const [callableEditorOpen, setCallableEditorOpen] = useState(false); const [callableName, setCallableName] = useState(''); const [selectedCallableId, setSelectedCallableId] = useState(''); const [callableArguments, setCallableArguments] = useState('[]'); const [callableResult, setCallableResult] = useState(); const [gatewaySuggestion, setGatewaySuggestion] = useState(); const load = useCallback(async () => { const tabId = tab?.id; if (!tabId) { setSnapshot(undefined); return; } try { const target = { tabId, frameId: 0 }; const status = await request('recording.status', target); const next = status.startedAt ? await request('recording.get', { ...target, limit: 500 }) : emptySnapshot(tabId); setSnapshot(next); if (next.status.options) setCaptureValues(next.status.options.captureValues); setLoadError(''); } catch (error) { setLoadError(errorMessage(error)); } }, [tab?.id, tab?.url]); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!snapshot?.status.active) return undefined; const timer = window.setInterval(() => void load(), 350); return () => window.clearInterval(timer); }, [load, snapshot?.status.active]); useEffect(() => { const listener = (message: unknown) => { const input = message as { action?: string; payload?: { tabId?: number } }; if (input.action === 'recording.changed' && input.payload?.tabId === tab?.id) void load(); }; browser.runtime.onMessage.addListener(listener); return () => browser.runtime.onMessage.removeListener(listener); }, [load, tab?.id]); useEffect(() => { const traces = snapshot?.traces || []; setSelectedTraceId((current) => traces.some((trace) => trace.id === current) ? current : traces[0]?.id || ''); }, [snapshot?.traces]); const selectedTrace = snapshot?.traces.find((trace) => trace.id === selectedTraceId); const traceEvents = useMemo(() => selectedTrace ? selectedTrace.eventIds.map((id) => snapshot?.events.find((event) => event.id === id)).filter((event): event is BrowserRecordingEvent => Boolean(event)) : [], [selectedTrace, snapshot?.events]); useEffect(() => { setSelectedEventId((current) => traceEvents.some((event) => event.id === current) ? current : traceEvents.find((event) => event.callableCapable)?.id || traceEvents.at(-1)?.id || ''); }, [traceEvents]); const selectedEvent = snapshot?.events.find((event) => event.id === selectedEventId); const selectedCallable = snapshot?.callables.find((callable) => callable.id === selectedCallableId); const recordingTarget = tab ? { tabId: tab.id, frameId: 0 } : undefined; const documentAvailable = snapshot?.status.documentAvailable !== false; const callableTarget = snapshot?.status.startedAt && documentAvailable ? snapshot.status.target : undefined; useEffect(() => { if (!selectedEvent) return; setCallableName(`${eventTitle(selectedEvent)} 页面函数`); const sample = selectedEvent.inputPreview || selectedEvent.inputs.find((item) => item.preview)?.preview; setCallableArguments(JSON.stringify(sample === undefined ? [] : [sample], null, 2)); setCallableEditorOpen(false); setCallableResult(undefined); }, [selectedEvent?.id, selectedEvent?.inputPreview]); useEffect(() => { const callables = snapshot?.callables || []; setSelectedCallableId((current) => callables.some((callable) => callable.id === current) ? current : callables.at(-1)?.id || ''); }, [snapshot?.callables]); const start = () => run(async () => { if (!tab) throw new Error('请选择目标标签页'); const next = await request('recording.start', { tabId: tab.id, captureValues, maxEntries: 500, maxValueBytes: 8_192, }); setSnapshot(next); setSelectedTraceId(''); setSelectedEventId(''); setCallableResult(undefined); }, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链'); const stop = () => run(async () => { if (!recordingTarget) return; setSnapshot(await request('recording.stop', recordingTarget)); }, '录制已停止,可以继续验证页面函数'); const clear = () => run(async () => { if (!recordingTarget) return; setSnapshot(await request('recording.clear', recordingTarget)); setCallableResult(undefined); }, '录制与录制型页面函数已清空'); const createCallable = () => run(async () => { if (snapshot?.status.active) throw new Error('请先停止录制,再保存页面函数'); if (!selectedEventAvailable || !callableTarget || !selectedEvent?.callHandleId) { throw new Error(selectedEvent ? '该调用属于另一个页面文档;返回对应页面现场后才能保存' : '当前事件没有可执行调用句柄'); } const callable = await request('callable.create', { ...callableTarget, source: 'recording', callHandleId: selectedEvent.callHandleId, name: callableName, }); setSnapshot((current) => current ? { ...current, callables: [...current.callables.filter((item) => item.id !== callable.id), callable] } : current); setSelectedCallableId(callable.id); setCallableEditorOpen(false); setCallableResult(undefined); }, '页面函数已创建'); const executeCallable = () => run(async () => { if (!callableTarget || !selectedCallable) throw new Error(documentAvailable ? '请选择页面函数' : '页面已经导航,旧文档的页面函数不可再执行'); let args: unknown; try { args = JSON.parse(callableArguments); } catch { throw new Error('调用参数必须是有效的 JSON 数组'); } if (!Array.isArray(args)) throw new Error('调用参数必须是 JSON 数组'); setCallableResult(await request('callable.execute', { ...callableTarget, callableId: selectedCallable.id, args })); }, '页面函数验证完成'); const deleteCallable = () => run(async () => { if (!callableTarget || !selectedCallable) return; const callables = await request('callable.delete', { ...callableTarget, callableId: selectedCallable.id }); setSnapshot((current) => current ? { ...current, callables } : current); setCallableResult(undefined); }, '页面函数已删除'); const active = Boolean(snapshot?.status.active); const hasRecording = Boolean(snapshot?.status.startedAt); const currentDocumentId = snapshot?.status.target.documentId; const selectedEventAvailable = eventAvailableInDocument(selectedEvent, currentDocumentId, documentAvailable); const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : []; const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : []; const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || []; const selectedCandidate = traceCandidates.find((candidate) => ( candidate.sources.some((source) => source.eventId === selectedEventId) || candidate.request.eventId === selectedEventId )) || traceCandidates[0]; const candidateSourceEvent = selectedCandidate ? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId) : undefined; const candidateAvailable = eventAvailableInDocument(candidateSourceEvent, currentDocumentId, documentAvailable); const canDeepCapture = CHROMIUM_CONTEXT_TOOLS && selectedEventAvailable && Boolean(selectedEvent && ['crypto', 'fetch', 'xhr', 'form', 'beacon', 'worker', 'message'].includes(selectedEvent.kind) && (selectedEvent.url || selectedEvent.wrapperHandleId)); const prepareCallableEditor = () => { if (!selectedEventAvailable) return; if (!active) { setCallableEditorOpen(true); return; } void run(async () => { if (!recordingTarget) throw new Error('目标标签页不可用'); setSnapshot(await request('recording.stop', recordingTarget)); setCallableEditorOpen(true); }, '录制已停止,请确认页面函数名称'); }; const continueInference = (candidate: BrowserProfileInferenceCandidate) => { setSelectedEventId(candidate.capturePlan?.matcherEventId || (candidate.sources.length > 1 ? candidate.request.eventId : candidate.source.eventId)); setAutoArmRequest((current) => current + 1); setWorkspaceMode('deep'); }; const openSuggestedGateway = async ( candidate: BrowserProfileInferenceCandidate, callable: BrowserPageCallable, capturedSample?: CapturedCallableSample, ) => { if (!tab) throw new Error('目标标签页已经关闭'); const sourceEvent = snapshot?.events.find((item) => item.id === candidate.source.eventId); const profile = await request('transform.profile.save', createBrowserTransformProfileInput( tab, sourceEvent, callable, candidate, )); setSnapshot((current) => current ? { ...current, callables: [...current.callables.filter((item) => item.id !== callable.id), callable], } : current); setGatewaySuggestion((current) => ({ revision: (current?.revision || 0) + 1, candidate, callable, profile, sampleBody: capturedSample?.body || shortSample(sourceEvent), sampleLabel: capturedSample?.label || (sourceEvent ? `${eventTitle(sourceEvent)} · arg 0` : undefined), })); setWorkspaceMode('gateway'); }; const createSuggestedGateway = (candidate: BrowserProfileInferenceCandidate) => run(async () => { if (candidate.sources.length !== 1) { throw new Error('多调用请求需要先捕获上层业务函数,不能把相互依赖的低层调用拆开回放'); } if (!candidateAvailable || !recordingTarget || !candidate.source.callHandleId) { throw new Error(candidateAvailable ? '推断候选没有可复用的页面调用句柄' : '该函数属于另一个页面文档,请返回对应页面现场后再生成'); } let currentSnapshot = snapshot; if (currentSnapshot?.status.active) { currentSnapshot = await request('recording.stop', recordingTarget); setSnapshot(currentSnapshot); } if (!currentSnapshot) throw new Error('没有可用的录制现场'); const target = currentSnapshot.status.target; if (!target) throw new Error('录制文档已经失效'); let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId); if (!callable) { callable = await request('callable.create', { ...target, source: 'recording', callHandleId: candidate.source.callHandleId, name: `${candidate.source.crypto?.algorithm || candidate.source.crypto?.operation || candidate.source.operation} 页面函数`, }); } await openSuggestedGateway(candidate, callable); }, '已根据录制证据生成并保存明文网关'); return
浏览器现场

{workspaceMode === 'gateway' ? '浏览器明文网关' : workspaceMode === 'recording' ? '操作与加解密录制' : '业务函数深度捕获'}

{CHROMIUM_CONTEXT_TOOLS && } {CHROMIUM_CONTEXT_TOOLS && }
{active ? `${snapshot?.status.count || 0} 个事件` : hasRecording ? '可分析' : '未录制'} {active ? : }