import { browser } from 'wxt/browser'; import type { BrowserPageCallable, BrowserPageCallableExecution, BrowserTarget, BrowserTransformDirection, BrowserTransformDirectionName, BrowserTransformExecution, BrowserTransformPacket, } from '@/types/models'; import { ExtensionError } from '@/shared/errors'; import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets'; import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants'; import { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client'; import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model'; import { serializeTabExecution, withPageNetworkGuard } from './network-guard'; import { MAX_CALLABLE_TIMEOUT_MS, MIN_CALLABLE_TIMEOUT_MS, callableExecutionPolicy, } from './execution'; const MAX_CALLABLES = 128; type RawCallable = Omit & { target?: BrowserTarget }; function normalizeTransaction(value: unknown): BrowserPageCallable['transaction'] { if (!value || typeof value !== 'object') return undefined; const input = value as Partial>; const request = input.request as Partial['request']> | undefined; if (input.version !== 2 || !Array.isArray(input.prerequisites) || input.prerequisites.length > 4 || !request || !['fetch', 'xhr', 'beacon', 'form'].includes(String(request.boundary)) || typeof request.method !== 'string' || typeof request.url !== 'string' || !Array.isArray(request.expectedDestinations) || request.expectedDestinations.length === 0 || request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim()) || !['json', 'form', 'raw'].includes(String(request.bodyFormat))) return undefined; const prerequisites = input.prerequisites.flatMap((value) => { if (!value || typeof value !== 'object') return []; const item = value as Partial['prerequisites'][number]>; const response = item.response as Partial['prerequisites'][number]['response']> | undefined; if (item.boundary !== 'fetch' || typeof item.method !== 'string' || typeof item.url !== 'string' || !['none', 'json', 'form', 'raw'].includes(String(item.requestBodyFormat)) || !Number.isSafeInteger(item.maxRequestBodyBytes) || Number(item.maxRequestBodyBytes) < 0 || Number(item.maxRequestBodyBytes) > 8 * 1_024 * 1_024 || !response || !Number.isSafeInteger(response.statusCode) || Number(response.statusCode) < 100 || Number(response.statusCode) > 599 || typeof response.url !== 'string' || !['json', 'form', 'raw'].includes(String(response.bodyFormat)) || !Number.isSafeInteger(response.maxBodyBytes) || Number(response.maxBodyBytes) < 1 || Number(response.maxBodyBytes) > 8 * 1_024 * 1_024 || !Array.isArray(response.requiredPaths) || response.requiredPaths.length === 0 || response.requiredPaths.some((path) => typeof path !== 'string' || !path.trim())) return []; return [{ boundary: 'fetch' as const, method: item.method.toUpperCase().slice(0, 16), url: item.url.slice(0, 4_096), requestBodyFormat: item.requestBodyFormat as NonNullable['prerequisites'][number]['requestBodyFormat'], maxRequestBodyBytes: Number(item.maxRequestBodyBytes), response: { statusCode: Number(response.statusCode), url: response.url.slice(0, 4_096), bodyFormat: response.bodyFormat as NonNullable['prerequisites'][number]['response']['bodyFormat'], maxBodyBytes: Number(response.maxBodyBytes), requiredPaths: [...new Set(response.requiredPaths.map((path) => path.trim().slice(0, 512)))].slice(0, 64), }, }]; }); if (prerequisites.length !== input.prerequisites.length) return undefined; return { version: 2, prerequisites, request: { boundary: request.boundary as NonNullable['request']['boundary'], method: request.method.toUpperCase().slice(0, 16), url: request.url.slice(0, 4_096), expectedDestinations: [...new Set(request.expectedDestinations.map((item) => item.trim().slice(0, 512)))].slice(0, 64), bodyFormat: request.bodyFormat as NonNullable['request']['bodyFormat'], }, inputMode: 'auto', }; } function normalizeExecution(value: unknown): BrowserPageCallable['execution'] | undefined { if (!value || typeof value !== 'object') return undefined; const input = value as Partial; if (!['sync', 'promise', 'auto'].includes(String(input.resultMode)) || !Number.isSafeInteger(input.timeoutMs) || Number(input.timeoutMs) < MIN_CALLABLE_TIMEOUT_MS || Number(input.timeoutMs) > MAX_CALLABLE_TIMEOUT_MS) return undefined; return callableExecutionPolicy(input.resultMode as BrowserPageCallable['execution']['resultMode'], Number(input.timeoutMs)); } function normalizeCallableAnalysis( value: unknown, ): BrowserPageCallable['provenance']['analysis'] | undefined { if (!value || typeof value !== 'object') return undefined; const input = value as Partial>; if (input.version !== 1 || typeof input.traceId !== 'string' || !input.confidence || typeof input.confidence !== 'object' || !Number.isFinite(input.confidence.score) || !['high', 'medium', 'low'].includes(String(input.confidence.level)) || !Array.isArray(input.flow) || !Array.isArray(input.operations) || !Array.isArray(input.evidence)) return undefined; const evidenceKinds = new Set([ 'request-boundary', 'response-boundary', 'exact-value', 'message-boundary', 'state-sequence', 'transform-lineage', 'callable', 'trace-order', 'heuristic', ]); return { version: 1, traceId: input.traceId.slice(0, 160), confidence: { score: Math.max(0, Math.min(100, Number(input.confidence.score))), level: input.confidence.level as 'high' | 'medium' | 'low', }, flow: input.flow.slice(0, 32).flatMap((item) => typeof item === 'string' ? [item.slice(0, 240)] : []), operations: input.operations.slice(0, 16).flatMap((operation) => ( operation && typeof operation.operation === 'string' ? [{ operation: operation.operation.slice(0, 240), destination: typeof operation.destination === 'string' ? operation.destination.slice(0, 512) : undefined, crypto: normalizeBrowserRecordingCrypto(operation.crypto), }] : [] )), evidence: input.evidence.slice(0, 24).flatMap((item) => ( item && evidenceKinds.has(String(item.kind)) && ['proven', 'supported'].includes(String(item.strength)) && typeof item.label === 'string' ? [{ kind: item.kind, strength: item.strength, label: item.label.slice(0, 500), }] : [] )), }; } function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCallable | undefined { if (!value || typeof value !== 'object') return undefined; const input = value as Partial; if (typeof input.id !== 'string' || typeof input.name !== 'string' || !['recorded-call', 'business-closure', 'request-transaction', 'global-function'].includes(String(input.kind)) || typeof input.operation !== 'string' || typeof input.origin !== 'string' || input.lifecycle !== 'document' || !Array.isArray(input.inputSlots) || !input.output || typeof input.output !== 'object' || !input.provenance || typeof input.provenance !== 'object') return undefined; const transaction = normalizeTransaction(input.transaction); const execution = normalizeExecution(input.execution); if (!execution) return undefined; if (input.kind === 'request-transaction' && !transaction) return undefined; const outputShape = input.output.shape; const outputPaths = input.output.paths; if (!['value', 'envelope'].includes(String(outputShape)) || !Array.isArray(outputPaths) || outputPaths.some((item) => typeof item !== 'string') || (outputShape === 'envelope' && outputPaths.length === 0)) return undefined; if (input.kind === 'request-transaction') { const expected = [...new Set(transaction!.request.expectedDestinations)].sort(); const declared = [...new Set(outputPaths)].sort(); if (outputShape !== 'envelope' || expected.length !== declared.length || expected.some((path, index) => path !== declared[index])) return undefined; } return { id: input.id.slice(0, 160), name: input.name.slice(0, 120), kind: input.kind as BrowserPageCallable['kind'], operation: input.operation.slice(0, 240), algorithm: typeof input.algorithm === 'string' ? input.algorithm.slice(0, 240) : undefined, crypto: normalizeBrowserRecordingCrypto(input.crypto), origin: input.origin.slice(0, 2_048), target: { ...target }, lifecycle: 'document', execution, inputSlots: input.inputSlots.slice(0, 64).map((slot, index) => { const item = slot as Partial; return { id: typeof item.id === 'string' ? item.id.slice(0, 120) : `arg-${index}`, name: typeof item.name === 'string' ? item.name.slice(0, 120) : `arg${index}`, index: Number.isSafeInteger(item.index) ? Number(item.index) : index, role: ['data', 'key', 'iv', 'algorithm', 'options', 'signature', 'salt', 'nonce', 'aad', 'unknown'].includes(String(item.role)) ? item.role as BrowserPageCallable['inputSlots'][number]['role'] : 'unknown', dataType: typeof item.dataType === 'string' ? item.dataType.slice(0, 120) : 'unknown', required: item.required !== false, retained: item.retained === true, }; }), output: { dataType: typeof input.output.dataType === 'string' ? input.output.dataType.slice(0, 120) : 'unknown', encoding: ['auto', 'utf8', 'hex', 'base64', 'json'].includes(String(input.output.encoding)) ? input.output.encoding as BrowserPageCallable['output']['encoding'] : 'auto', shape: outputShape as BrowserPageCallable['output']['shape'], paths: outputPaths.slice(0, 64).map((item) => item.slice(0, 512)), }, transaction, provenance: { recordingId: typeof input.provenance.recordingId === 'string' ? input.provenance.recordingId.slice(0, 160) : undefined, traceId: typeof input.provenance.traceId === 'string' ? input.provenance.traceId.slice(0, 160) : undefined, eventId: typeof input.provenance.eventId === 'string' ? input.provenance.eventId.slice(0, 160) : undefined, sourceUrl: typeof input.provenance.sourceUrl === 'string' ? input.provenance.sourceUrl.slice(0, 4_096) : undefined, lineNumber: Number.isSafeInteger(input.provenance.lineNumber) ? Math.max(1, Number(input.provenance.lineNumber)) : undefined, functionName: typeof input.provenance.functionName === 'string' ? input.provenance.functionName.slice(0, 240) : undefined, businessFrameHints: Array.isArray(input.provenance.businessFrameHints) ? input.provenance.businessFrameHints.slice(0, 16).flatMap((hint) => ( hint && typeof hint.functionName === 'string' && Number.isFinite(hint.support) && Number.isFinite(hint.averageDepth) ? [{ functionName: hint.functionName.slice(0, 240), url: typeof hint.url === 'string' ? hint.url.slice(0, 4_096) : undefined, support: Math.max(0, Number(hint.support)), averageDepth: Math.max(0, Number(hint.averageDepth)), }] : [] )) : undefined, analysis: normalizeCallableAnalysis(input.provenance.analysis), }, createdAt: Number.isFinite(input.createdAt) ? Math.max(0, Number(input.createdAt)) : Date.now(), }; } type PageControllerCommand = 'callable.list' | 'callable.execute' | 'callable.delete' | 'transform.execute'; function injectionErrorMessage(value: unknown): string { if (value instanceof Error) return value.message; if (typeof value === 'string') return value; if (value && typeof value === 'object' && typeof (value as { message?: unknown }).message === 'string') { return (value as { message: string }).message; } return String(value || '页面脚本执行失败'); } async function pageCallableCommand( registryKey: string, protocolVersion: number, command: PageControllerCommand, input: Record, ): Promise { const controller = (window as unknown as Record)[registryKey] as { version?: unknown; command?: (name: PageControllerCommand, params: Record) => unknown; } | undefined; if (controller?.version !== protocolVersion || typeof controller.command !== 'function') { if (command === 'callable.list') return []; throw new Error('页面函数控制器不存在,页面可能已经刷新'); } try { return await Promise.resolve(controller.command(command, input)); } catch (error) { return { __yakitCallError: { message: error instanceof Error ? error.message : String(error), } }; } } async function callPageController( target: BrowserTarget, command: PageControllerCommand, input: Record = {}, ): Promise { if (command === 'callable.execute' || command === 'transform.execute') { return serializeTabExecution(target.tabId, async () => { const current = await resolveDocumentTarget(target); const callables = await callPageController(current, 'callable.list') as RawCallable[]; const ids = command === 'callable.execute' ? [input.callableId] : (input.direction as BrowserTransformDirection).nodes.flatMap((node) => node.kind === 'page.call' ? [node.callableId] : []); const used = callables.filter((callable) => ids.includes(callable.id)); if (used.length !== new Set(ids).size) throw new ExtensionError('callable_unavailable', '页面函数已经失效'); const prerequisites = used.flatMap((callable) => callable.transaction?.prerequisites || []); return withPageNetworkGuard(current, prerequisites, () => invokePageController(current, command, input)); }); } return invokePageController(target, command, input); } async function invokePageController( target: BrowserTarget, command: PageControllerCommand, input: Record, ): Promise { if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input); const [result] = await browser.scripting.executeScript({ target: scriptingTarget(target), world: 'MAIN', func: pageCallableCommand, args: [PAGE_RECORDER_REGISTRY_KEY, PAGE_RECORDER_PROTOCOL_VERSION, command, input], }); const injectionError = (result as (typeof result & { error?: unknown }) | undefined)?.error; if (injectionError !== undefined) { throw new ExtensionError('page_callable_execution_failed', injectionErrorMessage(injectionError)); } const failure = (result?.result as { __yakitCallError?: { message: string } } | undefined)?.__yakitCallError; if (failure) throw new ExtensionError('page_callable_execution_failed', failure.message); return result?.result; } export async function listPageCallables(target: BrowserTarget): Promise { const resolved = await resolveDocumentTarget(target); const result = await callPageController(resolved, 'callable.list'); if (!Array.isArray(result)) return []; return result.map((item) => normalizeCallable(item, resolved)) .filter((item): item is BrowserPageCallable => Boolean(item)).slice(-MAX_CALLABLES); } export async function executePageCallable( target: BrowserTarget, callableId: string, args: unknown[], ): Promise { const resolved = await resolveDocumentTarget(target); const value = await callPageController(resolved, 'callable.execute', { callableId, args }) as BrowserPageCallableExecution | undefined; if (!value || value.callableId !== callableId || typeof value.durationMs !== 'number') { throw new ExtensionError('callable_invalid_result', '页面函数没有返回有效结果'); } return value; } export async function deletePageCallable(target: BrowserTarget, callableId: string): Promise { const resolved = await resolveDocumentTarget(target); const result = await callPageController(resolved, 'callable.delete', { callableId }); if (!Array.isArray(result)) return []; return result.map((item) => normalizeCallable(item, resolved)) .filter((item): item is BrowserPageCallable => Boolean(item)); } export async function executePageTransformDirection( target: BrowserTarget, profileId: string, directionName: BrowserTransformDirectionName, direction: BrowserTransformDirection, packet: BrowserTransformPacket, ): Promise { const resolved = await resolveDocumentTarget(target); const result = await callPageController(resolved, 'transform.execute', { profileId, directionName, direction, packet, }) as { ok?: unknown; value?: BrowserTransformExecution; error?: { code?: unknown; message?: unknown }; } | undefined; if (!result?.ok) { throw new ExtensionError( typeof result?.error?.code === 'string' ? result.error.code : 'transform_page_execution_failed', typeof result?.error?.message === 'string' ? result.error.message : '页面没有返回有效的 Pipeline 结果', ); } if (!result.value || result.value.profileId !== profileId || result.value.direction !== directionName) { throw new ExtensionError('transform_invalid_result', '页面没有返回有效的 Pipeline 结果'); } return result.value; } export { normalizeCallable };