feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+704 -16
View File
@@ -1,15 +1,33 @@
import { browser } from 'wxt/browser';
import * as v from 'valibot';
import { executePageTransformDirection, listPageCallables } from '@/features/page-callable/service';
import { resolveDocumentTarget } from '@/platform/browser/targets';
import {
deletePageCallable,
executePageTransformDirection,
listPageCallables,
} from '@/features/page-callable/service';
import {
createCapturedPageCallable,
deepCaptureStatus,
detachDeepCapture,
startDeepCapture,
type DeepCaptureOwner,
} from '@/features/deep-capture/service';
import { getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import { browserTransformProfileSchema } from '@/protocol/transform';
import type {
BrowserDeepCaptureStatus,
BrowserPageCallable,
BrowserTarget,
BrowserTransformExecuteInput,
BrowserTransformExecution,
BrowserTransformDirectionName,
BrowserTransformPacket,
BrowserTransformPipelineNode,
BrowserTransformProfile,
BrowserTransformProfileInput,
BrowserTransformRequestTransactionBinding,
BrowserTransformRecoveryPlan,
BrowserTransformRecoveryValidationResult,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
import { assertTransformDirection, assertTransformRoute } from './mapping';
@@ -19,10 +37,19 @@ import {
type TransformExecutionGate,
} from './concurrency';
import { deleteBrowserTransformReplayDrafts } from './replay-draft';
import { listCookies } from '@/features/cookies/service';
import {
compileBrowserTransformRecoveryDirections,
createBrowserTransformRecoveryPlan,
recoveryTargetMatches,
staleBrowserTransformProfile,
} from './recovery-plan';
import { createBrowserTransformExplanation } from './explanation';
const STORAGE_KEY = 'browser-transform-profiles.v2';
const STORAGE_KEY = 'browser-transform-profiles.v4';
const MAX_PROFILES = 64;
const MAX_QUEUE_DEPTH = 128;
const RECOVERY_VALIDATION_TTL_MS = 30 * 60 * 1_000;
interface ProfileStore {
profiles: BrowserTransformProfile[];
@@ -30,6 +57,7 @@ interface ProfileStore {
const mutationQueues = new Map<string, Promise<void>>();
const executionGates = new Map<string, TransformExecutionGate>();
let initialized = false;
function profileOrigin(value: string): string {
try {
@@ -74,7 +102,42 @@ function normalizeDirection(input: BrowserTransformProfileInput['request']): Bro
};
}
function normalizeProfile(input: BrowserTransformProfileInput, previous?: BrowserTransformProfile): BrowserTransformProfile {
interface TransformIsolationBinding {
isolationContextId: string;
cookieStoreId?: string;
}
async function currentTransformIsolation(target: BrowserTarget): Promise<TransformIsolationBinding> {
const tab = await getTab(target.tabId);
if (!tab.isolationContextId) {
throw new ExtensionError('isolation_unavailable', '无法确认明文网关页面所属的身份隔离上下文');
}
return {
isolationContextId: tab.isolationContextId,
cookieStoreId: tab.cookieStoreId,
};
}
function assertProfileIsolation(
profile: Pick<BrowserTransformProfile, 'isolationContextId' | 'cookieStoreId'>,
binding: TransformIsolationBinding,
): void {
if (
profile.isolationContextId !== binding.isolationContextId
|| profile.cookieStoreId !== binding.cookieStoreId
) {
throw new ExtensionError(
'isolation_stale',
'明文网关绑定的身份隔离上下文已经变化,请重新录制并创建网关',
);
}
}
function normalizeProfile(
input: BrowserTransformProfileInput,
isolation: TransformIsolationBinding,
previous?: BrowserTransformProfile,
): BrowserTransformProfile {
const now = Date.now();
const name = input.name.trim().slice(0, 120);
const origin = profileOrigin(input.origin);
@@ -93,6 +156,8 @@ function normalizeProfile(input: BrowserTransformProfileInput, previous?: Browse
name,
enabled: input.enabled,
target: { ...input.target },
isolationContextId: isolation.isolationContextId,
cookieStoreId: isolation.cookieStoreId,
origin,
match: { methods, urlPattern },
request: normalizeDirection(input.request),
@@ -155,9 +220,44 @@ export async function getBrowserTransformProfile(id: string): Promise<BrowserTra
export async function saveBrowserTransformProfile(input: BrowserTransformProfileInput): Promise<BrowserTransformProfile> {
const target = await resolveDocumentTarget(input.target);
const isolation = await currentTransformIsolation(target);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
const origin = profileOrigin(frame?.url || '');
if (!origin || origin !== input.origin) throw new ExtensionError('origin_changed', '目标页面来源已经变化,请重新绑定转换配置');
const callables = await assertProfileCallablesAvailable(target, input);
const requestTransaction = deriveRequestTransactionBinding(input, callables);
return mutateStore('profiles', (store) => {
const previous = input.id ? store.profiles.find((profile) => profile.id === input.id) : undefined;
if (previous && (previous.target.tabId !== target.tabId
|| previous.target.frameId !== target.frameId
|| previous.target.documentId !== target.documentId
|| previous.origin !== input.origin)) {
throw new ExtensionError('transform_target_changed', '现有转换配置不能改绑到另一个页面文档,请新建配置');
}
if (previous) assertProfileIsolation(previous, isolation);
const normalized = withRequestTransactionBinding(
normalizeProfile({
...input,
target,
maxConcurrency: transactionSafeConcurrency(input, callables),
}, isolation, previous),
requestTransaction,
);
const explained = withTransformExplanation(normalized, callables, previous);
const profileWithPreviousRecovery = previous?.recovery
? { ...explained, recovery: previous.recovery }
: explained;
const recovery = createBrowserTransformRecoveryPlan(profileWithPreviousRecovery, callables);
const profile = recovery ? { ...explained, recovery } : explained;
const profiles = [profile, ...store.profiles.filter((item) => item.id !== profile.id)].slice(0, MAX_PROFILES);
return [{ profiles }, profile];
});
}
async function assertProfileCallablesAvailable(
target: BrowserTarget,
input: Pick<BrowserTransformProfileInput, 'request' | 'response'>,
): Promise<BrowserPageCallable[]> {
const callables = await listPageCallables(target);
const callableIds = new Set(callables.map((callable) => callable.id));
const referenced = [
@@ -167,20 +267,542 @@ export async function saveBrowserTransformProfile(input: BrowserTransformProfile
.map((node) => node.callableId);
const missing = referenced.find((callableId) => !callableIds.has(callableId));
if (missing) throw new ExtensionError('callable_unavailable', `页面函数已经失效: ${missing}`);
return callables;
}
function transactionSafeConcurrency(
input: Pick<BrowserTransformProfileInput, 'request' | 'response' | 'maxConcurrency'>,
callables: BrowserPageCallable[],
): number {
const referenced = new Set([
...(input.request.enabled ? input.request.nodes : []),
...(input.response.enabled ? input.response.nodes : []),
].flatMap((node) => node.kind === 'page.call' ? [node.callableId] : []));
return callables.some((callable) => referenced.has(callable.id) && callable.kind === 'request-transaction')
? 1
: input.maxConcurrency;
}
function deriveRequestTransactionBinding(
input: Pick<BrowserTransformProfileInput, 'request' | 'response'>,
callables: BrowserPageCallable[],
): BrowserTransformRequestTransactionBinding | undefined {
const byId = new Map(callables.map((callable) => [callable.id, callable]));
const nodes = (['request', 'response'] as const).flatMap((direction) => (
input[direction].enabled
? input[direction].nodes.flatMap((node) => {
if (node.kind !== 'page.call') return [];
const callable = byId.get(node.callableId);
return callable?.kind === 'request-transaction' ? [{ direction, callable }] : [];
})
: []
));
if (!nodes.length) return undefined;
if (nodes.length !== 1) {
throw new ExtensionError(
'transform_profile_invalid',
'一个明文网关只能引用一个终止请求事务;请先在页面业务函数内保留完整 envelope',
);
}
const [{ direction, callable }] = nodes;
if (direction !== 'request') {
throw new ExtensionError('transform_profile_invalid', '请求事务只能用于请求转换方向');
}
if (!callable.transaction) {
throw new ExtensionError('transform_profile_invalid', '请求事务缺少扩展验证过的网络计划');
}
return {
callableId: callable.id,
transaction: structuredClone(callable.transaction),
};
}
function withRequestTransactionBinding(
profile: BrowserTransformProfile,
binding: BrowserTransformRequestTransactionBinding | undefined,
): BrowserTransformProfile {
return binding ? { ...profile, requestTransaction: binding } : profile;
}
function withTransformExplanation(
profile: BrowserTransformProfile,
callables: BrowserPageCallable[],
previous?: BrowserTransformProfile,
): BrowserTransformProfile {
return {
...profile,
explanation: createBrowserTransformExplanation(profile, callables, previous?.explanation),
};
}
function assertRequestTransactionPacket(
profile: BrowserTransformProfile,
packet: BrowserTransformPacket,
): void {
const request = profile.requestTransaction?.transaction.request;
if (!request) return;
const method = (packet.method || '').toUpperCase();
let actualUrl: string;
let expectedUrl: string;
try {
const actual = new URL(packet.url, `${profile.origin}/`);
const expected = new URL(request.url, `${profile.origin}/`);
actualUrl = `${actual.origin}${actual.pathname}${actual.search}`;
expectedUrl = `${expected.origin}${expected.pathname}${expected.search}`;
} catch {
throw new ExtensionError('transform_route_mismatch', '请求事务无法验证当前数据包 URL');
}
if (method !== request.method.toUpperCase() || actualUrl !== expectedUrl) {
throw new ExtensionError(
'transform_route_mismatch',
`请求事务只允许 ${request.method.toUpperCase()} ${expectedUrl}`,
);
}
}
async function bindOnlineTransactionSession(
profile: BrowserTransformProfile,
execution: BrowserTransformExecution,
): Promise<BrowserTransformExecution> {
const transaction = profile.requestTransaction?.transaction;
if (!transaction?.prerequisites.length) return execution;
let requestUrl: string;
try {
requestUrl = new URL(transaction.request.url, `${profile.origin}/`).toString();
} catch {
throw new ExtensionError('transform_session_unavailable', '请求事务没有可解析的浏览器会话 URL');
}
let cookies;
try {
cookies = await listCookies(requestUrl, profile.cookieStoreId);
} catch (cause) {
throw new ExtensionError(
'transform_session_unavailable',
`无法读取在线请求事务绑定的浏览器 Cookie Store: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
const cookieHeader = [...cookies]
.sort((left, right) => right.path.length - left.path.length || left.name.localeCompare(right.name))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
const setHeaders = execution.setHeaders.filter((header) => header.name.toLowerCase() !== 'cookie');
const removeHeaders = execution.removeHeaders.filter((name) => name.toLowerCase() !== 'cookie');
if (cookieHeader) setHeaders.push({ name: 'Cookie', value: cookieHeader });
else removeHeaders.push('Cookie');
return { ...execution, setHeaders, removeHeaders };
}
function requireRecovery(profile: BrowserTransformProfile): BrowserTransformRecoveryPlan {
if (!profile.recovery) {
throw new ExtensionError('transform_recovery_unavailable', '这个明文网关没有可复用的页面捕获入口,需要重新录制并生成');
}
return profile.recovery;
}
async function updateRecovery(
profileId: string,
update: (profile: BrowserTransformProfile, recovery: BrowserTransformRecoveryPlan) => BrowserTransformProfile,
): Promise<BrowserTransformProfile> {
return mutateStore('profiles', (store) => {
const previous = input.id ? store.profiles.find((profile) => profile.id === input.id) : undefined;
if (previous && (previous.target.tabId !== target.tabId
|| previous.target.frameId !== target.frameId
|| previous.target.documentId !== target.documentId
|| previous.origin !== input.origin)) {
throw new ExtensionError('transform_target_changed', '现有转换配置不能改绑到另一个页面文档,请新建配置');
}
const profile = normalizeProfile({ ...input, target }, previous);
const profiles = [profile, ...store.profiles.filter((item) => item.id !== profile.id)].slice(0, MAX_PROFILES);
return [{ profiles }, profile];
const profile = store.profiles.find((item) => item.id === profileId);
if (!profile) throw new ExtensionError('transform_profile_not_found', '浏览器转换配置不存在或已删除');
const next = update(profile, requireRecovery(profile));
return [{
profiles: [next, ...store.profiles.filter((item) => item.id !== profileId)].slice(0, MAX_PROFILES),
}, next];
});
}
export async function invalidateBrowserTransformProfilesForDocument(
target: BrowserTarget,
reason = '页面已经刷新或导航,旧页面函数不可再执行',
): Promise<BrowserTransformProfile[]> {
if (!target.documentId) return (await readStore()).profiles;
return mutateStore('profiles', (store) => {
let changed = false;
const profiles = store.profiles.map((profile) => {
const stale = profile.target.tabId === target.tabId
&& profile.target.frameId === target.frameId
&& Boolean(profile.target.documentId)
&& profile.target.documentId !== target.documentId;
if (!stale) return profile;
changed = true;
return staleBrowserTransformProfile(profile, reason);
});
return [changed ? { profiles } : store, profiles];
});
}
export async function invalidateBrowserTransformProfilesForCallable(
target: BrowserTarget,
callableId: string,
): Promise<BrowserTransformProfile[]> {
return mutateStore('profiles', (store) => {
let changed = false;
const profiles = store.profiles.map((profile) => {
const referencesCallable = profile.target.tabId === target.tabId
&& profile.target.frameId === target.frameId
&& (!target.documentId || profile.target.documentId === target.documentId)
&& [profile.request, profile.response]
.flatMap((direction) => direction.enabled ? direction.nodes : [])
.some((node) => node.kind === 'page.call' && node.callableId === callableId);
if (!referencesCallable) return profile;
changed = true;
return staleBrowserTransformProfile(profile, '绑定的页面函数已被删除,需要重新捕获后再启用');
});
return [changed ? { profiles } : store, profiles];
});
}
export function initializeBrowserTransformService(): void {
if (initialized) return;
initialized = true;
browser.webNavigation.onCommitted.addListener((details) => {
if (!details.documentId) return;
void invalidateBrowserTransformProfilesForDocument({
tabId: details.tabId,
frameId: details.frameId,
documentId: details.documentId,
}).catch(console.error);
});
browser.tabs.onRemoved.addListener((tabId) => {
void mutateStore('profiles', (store) => {
let changed = false;
const profiles = store.profiles.map((profile) => {
if (profile.target.tabId !== tabId) return profile;
changed = true;
return staleBrowserTransformProfile(profile, '绑定的浏览器标签页已经关闭');
});
return [changed ? { profiles } : store, profiles];
}).catch(console.error);
});
}
export async function getBrowserTransformRecovery(profileId: string): Promise<BrowserTransformRecoveryPlan> {
return requireRecovery(await getBrowserTransformProfile(profileId));
}
export async function startBrowserTransformRecovery(
profileId: string,
owner: DeepCaptureOwner = { kind: 'local' },
): Promise<BrowserDeepCaptureStatus> {
let profile = await getBrowserTransformProfile(profileId);
let recovery = requireRecovery(profile);
if (!recovery.capture.automatic) {
throw new ExtensionError(
'transform_recovery_manual',
recovery.capture.reason || '当前明文网关需要人工确认页面函数映射',
);
}
const target = await resolveDocumentTarget({
tabId: profile.target.tabId,
frameId: profile.target.frameId,
});
assertProfileIsolation(profile, await currentTransformIsolation(target));
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (profileOrigin(frame?.url || '') !== profile.origin) {
throw new ExtensionError('origin_changed', '当前页面来源与明文网关不同,不能自动重新绑定');
}
if (profile.target.documentId !== target.documentId) {
await invalidateBrowserTransformProfilesForDocument(target);
profile = await getBrowserTransformProfile(profileId);
recovery = requireRecovery(profile);
}
if (recovery.state === 'ready' && profile.enabled) {
throw new ExtensionError('transform_recovery_not_needed', '当前明文网关仍然可用,不需要重新捕获');
}
await updateRecovery(profileId, (current, plan) => ({
...current,
enabled: false,
recovery: {
...plan,
state: 'capturing',
pending: undefined,
validation: undefined,
reason: '等待用户在目标页面重复一次原业务操作',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
try {
return await startDeepCapture(target, {
kind: 'request',
urlPattern: recovery.capture.urlPattern,
frameHints: recovery.capture.frameHints,
}, owner);
} catch (error) {
await updateRecovery(profileId, (current, plan) => ({
...current,
recovery: {
...plan,
state: 'failed',
reason: '无法武装页面调试会话;处理浏览器调试占用后可以重试',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
throw error;
}
}
export async function captureBrowserTransformRecovery(
profileId: string,
targetInput: BrowserTarget,
callFrameId: string,
strategy: 'selected-frame' | 'request-transaction',
owner: DeepCaptureOwner = { kind: 'local' },
): Promise<BrowserTransformRecoveryPlan> {
const profile = await getBrowserTransformProfile(profileId);
const recovery = requireRecovery(profile);
if (recovery.state !== 'capturing') {
throw new ExtensionError('transform_recovery_state', '恢复计划当前没有等待捕获页面函数');
}
const captureStatus = await deepCaptureStatus(targetInput, owner);
if (captureStatus.state !== 'paused' || !captureStatus.pause) {
throw new ExtensionError('deep_capture_not_paused', '页面尚未停在目标密码调用,请重新执行一次真实业务操作');
}
const target = captureStatus.target;
if (
captureStatus.isolationContextId !== profile.isolationContextId
|| captureStatus.cookieStoreId !== profile.cookieStoreId
) {
throw new ExtensionError('isolation_stale', '恢复现场不属于明文网关原来的身份隔离上下文');
}
if (
targetInput.tabId !== target.tabId
|| targetInput.frameId !== target.frameId
|| (
targetInput.documentId
&& target.documentId
&& targetInput.documentId !== target.documentId
)
) {
throw new ExtensionError('stale_document', '暂停的调用栈不属于当前恢复页面,请重新开始捕获');
}
if (target.tabId !== profile.target.tabId || target.frameId !== profile.target.frameId) {
throw new ExtensionError('target_denied', '恢复计划只能在原浏览器标签页和 Frame 中重新捕获');
}
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (profileOrigin(frame?.url || '') !== profile.origin) {
throw new ExtensionError('origin_changed', '当前页面来源与明文网关不同,不能接收恢复结果');
}
let callable: BrowserPageCallable | undefined;
try {
callable = await createCapturedPageCallable(
target,
callFrameId,
strategy === 'request-transaction'
? {
strategy,
name: recovery.binding.name,
transaction: recovery.binding.transaction || (() => {
throw new ExtensionError('transform_recovery_invalid', '恢复计划缺少原请求事务的在线依赖证据');
})(),
}
: { strategy, name: recovery.binding.name },
owner,
);
const directions = compileBrowserTransformRecoveryDirections(profile, recovery, callable);
const updated = await updateRecovery(profileId, (current, plan) => ({
...current,
enabled: false,
recovery: {
...plan,
state: 'validation-required',
pending: {
target,
callableId: callable!.id,
callableName: callable!.name,
request: directions.request,
response: directions.response,
capturedAt: Date.now(),
},
validation: undefined,
reason: '新页面函数已经捕获;使用本地回放样本验证后才能重新启用',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
return requireRecovery(updated);
} catch (error) {
if (callable) await deletePageCallable(target, callable.id).catch(() => undefined);
await updateRecovery(profileId, (current, plan) => ({
...current,
enabled: false,
recovery: {
...plan,
state: 'failed',
pending: undefined,
validation: undefined,
reason: '新页面函数没有通过恢复契约检查;可以重新捕获或重新分析当前页面',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
throw error;
}
}
function recoveryProfileInput(
profile: BrowserTransformProfile,
recovery: BrowserTransformRecoveryPlan,
enabled: boolean,
): BrowserTransformProfileInput {
if (!recovery.pending) throw new ExtensionError('transform_recovery_state', '恢复计划还没有捕获新的页面函数');
return {
id: profile.id,
name: profile.name,
enabled,
target: { ...recovery.pending.target },
origin: profile.origin,
match: structuredClone(profile.match),
request: structuredClone(recovery.pending.request),
response: structuredClone(recovery.pending.response),
failMode: 'closed',
maxConcurrency: profile.maxConcurrency,
};
}
export async function validateBrowserTransformRecovery(
profileId: string,
packet: BrowserTransformPacket,
): Promise<BrowserTransformRecoveryValidationResult> {
const profile = await getBrowserTransformProfile(profileId);
const recovery = requireRecovery(profile);
if (recovery.state !== 'validation-required' && recovery.state !== 'confirmation-required') {
throw new ExtensionError('transform_recovery_state', '请先重新捕获页面函数,再执行本地回放验证');
}
if (!recovery.pending) throw new ExtensionError('transform_recovery_state', '恢复计划还没有捕获新的页面函数');
const currentTarget = await resolveDocumentTarget({
tabId: recovery.pending.target.tabId,
frameId: recovery.pending.target.frameId,
});
assertProfileIsolation(profile, await currentTransformIsolation(currentTarget));
if (!recoveryTargetMatches(currentTarget, recovery.pending.target)) {
await invalidateBrowserTransformProfilesForDocument(currentTarget);
throw new ExtensionError('document_changed', '重新捕获后页面文档再次变化,请重新开始恢复');
}
try {
const { execution } = await validateBrowserTransformProfile(
recoveryProfileInput(profile, recovery, true),
packet,
);
const now = Date.now();
const validation = {
id: crypto.randomUUID(),
proofLevel: 'execution-only' as const,
summary: '新页面函数已在当前文档真实执行,Pipeline 全部节点成功完成',
validatedAt: now,
expiresAt: now + RECOVERY_VALIDATION_TTL_MS,
};
const updated = await updateRecovery(profileId, (current, plan) => ({
...current,
enabled: false,
recovery: {
...plan,
state: 'confirmation-required',
validation,
reason: '本地回放验证通过;确认后才会替换旧绑定并按原设置启用',
updatedAt: now,
},
updatedAt: now,
}));
return { recovery: requireRecovery(updated), execution };
} catch (error) {
await updateRecovery(profileId, (current, plan) => ({
...current,
enabled: false,
recovery: {
...plan,
state: 'validation-required',
validation: undefined,
reason: '本地回放没有通过;旧明文网关仍保持停用,可以修改输入后重试',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
throw error;
}
}
export async function confirmBrowserTransformRecovery(
profileId: string,
validationId: string,
): Promise<BrowserTransformProfile> {
const profile = await getBrowserTransformProfile(profileId);
const recovery = requireRecovery(profile);
if (recovery.state !== 'confirmation-required' || !recovery.validation || recovery.validation.id !== validationId) {
throw new ExtensionError('transform_recovery_state', '恢复验证凭据不存在或已经被替换');
}
if (recovery.validation.expiresAt <= Date.now()) {
throw new ExtensionError('transform_recovery_expired', '恢复验证已经过期,请重新执行本地回放');
}
if (!recovery.pending) throw new ExtensionError('transform_recovery_state', '恢复计划缺少新文档绑定');
const target = await resolveDocumentTarget({
tabId: recovery.pending.target.tabId,
frameId: recovery.pending.target.frameId,
});
const isolation = await currentTransformIsolation(target);
assertProfileIsolation(profile, isolation);
if (!recoveryTargetMatches(target, recovery.pending?.target)) {
await invalidateBrowserTransformProfilesForDocument(target);
throw new ExtensionError('document_changed', '验证后页面文档再次变化,请重新捕获');
}
const input = recoveryProfileInput(profile, recovery, recovery.desiredEnabled);
const callables = await assertProfileCallablesAvailable(target, input);
const requestTransaction = deriveRequestTransactionBinding(input, callables);
return mutateStore('profiles', (store) => {
const current = store.profiles.find((item) => item.id === profileId);
if (!current) throw new ExtensionError('transform_profile_not_found', '浏览器转换配置不存在或已删除');
const currentRecovery = requireRecovery(current);
if (currentRecovery.validation?.id !== validationId || currentRecovery.state !== 'confirmation-required') {
throw new ExtensionError('transform_recovery_state', '恢复验证状态已经变化');
}
assertProfileIsolation(current, isolation);
const normalized = withRequestTransactionBinding(
normalizeProfile(input, isolation, current),
requestTransaction,
);
const explained = withTransformExplanation(normalized, callables, current);
const withPreviousRecovery = { ...explained, recovery: currentRecovery };
const readyRecovery = createBrowserTransformRecoveryPlan(withPreviousRecovery, callables);
if (!readyRecovery) throw new ExtensionError('transform_recovery_failed', '新页面函数没有生成可持续恢复的绑定');
const ready = { ...explained, recovery: readyRecovery };
return [{
profiles: [ready, ...store.profiles.filter((item) => item.id !== profileId)].slice(0, MAX_PROFILES),
}, ready];
});
}
export async function resetBrowserTransformRecovery(
profileId: string,
owner: DeepCaptureOwner = { kind: 'local' },
): Promise<BrowserTransformRecoveryPlan> {
const profile = await getBrowserTransformProfile(profileId);
const recoveryTarget = {
tabId: profile.target.tabId,
frameId: profile.target.frameId,
};
const status = await deepCaptureStatus(recoveryTarget, owner).catch(() => undefined);
if (status && status.state !== 'detached') {
await detachDeepCapture(status.target, owner).catch(() => undefined);
}
const updated = await updateRecovery(profileId, (profile, recovery) => ({
...profile,
enabled: false,
recovery: {
...recovery,
state: 'stale',
pending: undefined,
validation: undefined,
reason: '恢复已取消;旧页面函数仍然失效,可以随时重新捕获',
updatedAt: Date.now(),
},
updatedAt: Date.now(),
}));
return requireRecovery(updated);
}
export async function deleteBrowserTransformProfile(id: string): Promise<BrowserTransformProfile[]> {
const profiles = await mutateStore('profiles', (store) => {
const profiles = store.profiles.filter((profile) => profile.id !== id);
@@ -207,14 +829,80 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
const direction = profile[input.direction];
if (!direction.enabled) throw new ExtensionError('transform_direction_disabled', `${input.direction === 'request' ? '请求' : '响应'}转换未启用`);
assertTransformRoute(profile.match.methods, profile.match.urlPattern, input.packet, profile.origin);
const target = await resolveDocumentTarget(profile.target);
assertRequestTransactionPacket(profile, input.packet);
let target: BrowserTarget;
try {
target = await resolveDocumentTarget(profile.target);
} catch (error) {
await updateRecovery(profile.id, (current) => staleBrowserTransformProfile(
current,
'页面文档已经变化,旧页面函数已停用并等待重新捕获',
)).catch(() => undefined);
throw error;
}
const isolation = await currentTransformIsolation(target);
assertProfileIsolation(profile, isolation);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (profileOrigin(frame?.url || '') !== profile.origin) {
throw new ExtensionError('origin_changed', '转换配置绑定的页面来源已经变化,请重新绑定');
}
const leave = await enterGate(profile);
try {
return await executePageTransformDirection(target, profile.id, input.direction, direction, input.packet);
const execution = await executePageTransformDirection(
target,
profile.id,
input.direction,
direction,
input.packet,
);
return input.direction === 'request'
? await bindOnlineTransactionSession(profile, execution)
: execution;
} finally {
leave();
}
}
export async function validateBrowserTransformProfile(
input: BrowserTransformProfileInput,
packet: BrowserTransformExecuteInput['packet'],
): Promise<{ profile: BrowserTransformProfile; execution: BrowserTransformExecution }> {
const target = await resolveDocumentTarget(input.target);
const isolation = await currentTransformIsolation(target);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (profileOrigin(frame?.url || '') !== input.origin) {
throw new ExtensionError('origin_changed', '候选明文网关绑定的页面来源已经变化,请重新分析当前页面');
}
const callables = await assertProfileCallablesAvailable(target, input);
const requestTransaction = deriveRequestTransactionBinding(input, callables);
const normalized = withRequestTransactionBinding(
normalizeProfile({
...input,
id: `validation-${crypto.randomUUID()}`,
target,
maxConcurrency: transactionSafeConcurrency(input, callables),
}, isolation),
requestTransaction,
);
const profile = withTransformExplanation(normalized, callables);
const directionName: BrowserTransformDirectionName = profile.request.enabled
? 'request'
: profile.response.enabled ? 'response' : 'request';
const direction = profile[directionName];
if (!profile.enabled || !direction.enabled) {
throw new ExtensionError('transform_direction_disabled', '候选明文网关没有启用任何转换方向');
}
assertTransformRoute(profile.match.methods, profile.match.urlPattern, packet, profile.origin);
assertRequestTransactionPacket(profile, packet);
const leave = await enterGate(profile);
try {
const execution = await executePageTransformDirection(target, profile.id, directionName, direction, packet);
return {
profile,
execution: directionName === 'request'
? await bindOnlineTransactionSession(profile, execution)
: execution,
};
} finally {
leave();
}