mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-24 04:01:53 +08:00
915 lines
37 KiB
TypeScript
915 lines
37 KiB
TypeScript
import { browser } from 'wxt/browser';
|
|
import * as v from 'valibot';
|
|
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';
|
|
import {
|
|
acquireTransformExecutionGate,
|
|
createTransformExecutionGate,
|
|
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.v4';
|
|
const MAX_PROFILES = 64;
|
|
const MAX_QUEUE_DEPTH = 128;
|
|
const RECOVERY_VALIDATION_TTL_MS = 30 * 60 * 1_000;
|
|
|
|
interface ProfileStore {
|
|
profiles: BrowserTransformProfile[];
|
|
}
|
|
|
|
const mutationQueues = new Map<string, Promise<void>>();
|
|
const executionGates = new Map<string, TransformExecutionGate>();
|
|
let initialized = false;
|
|
|
|
function profileOrigin(value: string): string {
|
|
try {
|
|
const origin = new URL(value).origin;
|
|
return origin === 'null' ? '' : origin;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function normalizeDirection(input: BrowserTransformProfileInput['request']): BrowserTransformProfileInput['request'] {
|
|
return {
|
|
enabled: input.enabled,
|
|
nodes: input.nodes.slice(0, 64).map((node): BrowserTransformPipelineNode => {
|
|
const base = { id: node.id.trim().slice(0, 160) || crypto.randomUUID(), name: node.name.trim().slice(0, 120) };
|
|
const reference = (value: { nodeId: string; path?: string }) => ({
|
|
nodeId: value.nodeId.trim().slice(0, 160),
|
|
path: value.path?.trim().slice(0, 512) || undefined,
|
|
});
|
|
if (node.kind === 'context.read') return { ...base, kind: node.kind, path: node.path.trim().slice(0, 512) };
|
|
if (node.kind === 'builtin') return {
|
|
...base,
|
|
kind: node.kind,
|
|
operation: node.operation,
|
|
inputs: node.inputs.slice(0, 64).map(reference),
|
|
options: node.options ? structuredClone(node.options) : undefined,
|
|
};
|
|
if (node.kind === 'page.call') return {
|
|
...base,
|
|
kind: node.kind,
|
|
callableId: node.callableId.trim().slice(0, 160),
|
|
arguments: node.arguments.slice(0, 64).map(reference),
|
|
};
|
|
return {
|
|
...base,
|
|
kind: node.kind,
|
|
destination: node.destination.trim().slice(0, 512),
|
|
source: reference(node.source),
|
|
encoding: node.encoding,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
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);
|
|
if (!name) throw new ExtensionError('transform_profile_invalid', '转换配置名称不能为空');
|
|
if (!origin || origin !== input.origin) throw new ExtensionError('transform_profile_invalid', '转换配置必须绑定有效的 HTTP(S) 页面来源');
|
|
if (!Number.isSafeInteger(input.target.tabId) || input.target.tabId < 1 || !Number.isSafeInteger(input.target.frameId) || input.target.frameId < 0) {
|
|
throw new ExtensionError('transform_profile_invalid', '转换配置的浏览器目标无效');
|
|
}
|
|
const methods = [...new Set(input.match.methods.map((method) => method.trim().toUpperCase()).filter(Boolean))].slice(0, 16);
|
|
if (methods.some((method) => !/^[A-Z][A-Z0-9_-]{0,31}$/.test(method))) {
|
|
throw new ExtensionError('transform_profile_invalid', '转换配置包含无效的 HTTP 方法');
|
|
}
|
|
const urlPattern = input.match.urlPattern.trim().slice(0, 2_048) || '*';
|
|
const profile: BrowserTransformProfile = {
|
|
id: previous?.id || input.id?.trim().slice(0, 160) || crypto.randomUUID(),
|
|
name,
|
|
enabled: input.enabled,
|
|
target: { ...input.target },
|
|
isolationContextId: isolation.isolationContextId,
|
|
cookieStoreId: isolation.cookieStoreId,
|
|
origin,
|
|
match: { methods, urlPattern },
|
|
request: normalizeDirection(input.request),
|
|
response: normalizeDirection(input.response),
|
|
failMode: 'closed',
|
|
maxConcurrency: Math.max(1, Math.min(8, Math.floor(input.maxConcurrency || 1))),
|
|
createdAt: previous?.createdAt || now,
|
|
updatedAt: now,
|
|
};
|
|
if (profile.request.enabled) assertTransformDirection(profile.request);
|
|
if (profile.response.enabled) assertTransformDirection(profile.response);
|
|
if (!profile.request.enabled && !profile.response.enabled) {
|
|
throw new ExtensionError('transform_profile_invalid', '转换配置必须至少启用请求或响应方向');
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
async function readStore(): Promise<ProfileStore> {
|
|
const stored = (await browser.storage.local.get(STORAGE_KEY))[STORAGE_KEY];
|
|
if (!stored || typeof stored !== 'object' || !Array.isArray((stored as ProfileStore).profiles)) return { profiles: [] };
|
|
const profiles: BrowserTransformProfile[] = [];
|
|
for (const candidate of (stored as ProfileStore).profiles.slice(0, MAX_PROFILES)) {
|
|
const parsed = v.safeParse(browserTransformProfileSchema, candidate);
|
|
if (parsed.success) profiles.push(parsed.output as BrowserTransformProfile);
|
|
}
|
|
return { profiles };
|
|
}
|
|
|
|
async function mutateStore<T>(key: string, mutate: (store: ProfileStore) => Promise<[ProfileStore, T]> | [ProfileStore, T]): Promise<T> {
|
|
const previous = mutationQueues.get(key) || Promise.resolve();
|
|
let release!: () => void;
|
|
const current = new Promise<void>((resolve) => { release = resolve; });
|
|
const queued = previous.then(() => current);
|
|
mutationQueues.set(key, queued);
|
|
await previous;
|
|
try {
|
|
const [next, result] = await mutate(await readStore());
|
|
await browser.storage.local.set({ [STORAGE_KEY]: next });
|
|
return result;
|
|
} finally {
|
|
release();
|
|
if (mutationQueues.get(key) === queued) mutationQueues.delete(key);
|
|
}
|
|
}
|
|
|
|
export async function listBrowserTransformProfiles(target?: Partial<BrowserTarget>): Promise<BrowserTransformProfile[]> {
|
|
const profiles = (await readStore()).profiles;
|
|
return profiles.filter((profile) => (
|
|
(target?.tabId === undefined || profile.target.tabId === target.tabId)
|
|
&& (target?.frameId === undefined || profile.target.frameId === target.frameId)
|
|
&& (target?.documentId === undefined || profile.target.documentId === target.documentId)
|
|
)).sort((left, right) => right.updatedAt - left.updatedAt);
|
|
}
|
|
|
|
export async function getBrowserTransformProfile(id: string): Promise<BrowserTransformProfile> {
|
|
const profile = (await readStore()).profiles.find((item) => item.id === id);
|
|
if (!profile) throw new ExtensionError('transform_profile_not_found', '浏览器转换配置不存在或已删除');
|
|
return profile;
|
|
}
|
|
|
|
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 = [
|
|
...(input.request.enabled ? input.request.nodes : []),
|
|
...(input.response.enabled ? input.response.nodes : []),
|
|
].filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
|
|
.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 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);
|
|
executionGates.delete(id);
|
|
return [{ profiles }, profiles];
|
|
});
|
|
await deleteBrowserTransformReplayDrafts(id);
|
|
return profiles;
|
|
}
|
|
|
|
async function enterGate(profile: BrowserTransformProfile): Promise<() => void> {
|
|
const gate = executionGates.get(profile.id) || createTransformExecutionGate();
|
|
executionGates.set(profile.id, gate);
|
|
const release = await acquireTransformExecutionGate(gate, profile.maxConcurrency, MAX_QUEUE_DEPTH);
|
|
return () => {
|
|
release();
|
|
if (!gate.active && !gate.queued) executionGates.delete(profile.id);
|
|
};
|
|
}
|
|
|
|
export async function executeBrowserTransform(input: BrowserTransformExecuteInput): Promise<BrowserTransformExecution> {
|
|
const profile = await getBrowserTransformProfile(input.profileId);
|
|
if (!profile.enabled) throw new ExtensionError('transform_profile_disabled', '浏览器转换配置已停用');
|
|
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);
|
|
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 {
|
|
const execution = await executePageTransformDirection(
|
|
target,
|
|
profile.id,
|
|
input.direction,
|
|
direction,
|
|
input.packet,
|
|
);
|
|
const result = input.direction === 'request'
|
|
? await bindOnlineTransactionSession(profile, execution)
|
|
: execution;
|
|
return { ...result, explanation: profile.explanation };
|
|
} finally {
|
|
leave();
|
|
}
|
|
}
|
|
|
|
export async function validateBrowserTransformProfile(
|
|
input: BrowserTransformProfileInput,
|
|
packet: BrowserTransformExecuteInput['packet'],
|
|
options: { direction?: BrowserTransformDirectionName; profileId?: string } = {},
|
|
): 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: options.profileId || `validation-${crypto.randomUUID()}`,
|
|
target,
|
|
maxConcurrency: transactionSafeConcurrency(input, callables),
|
|
}, isolation),
|
|
requestTransaction,
|
|
);
|
|
const profile = withTransformExplanation(normalized, callables);
|
|
const directionName: BrowserTransformDirectionName = options.direction || (profile.request.enabled
|
|
? 'request'
|
|
: profile.response.enabled ? 'response' : 'request');
|
|
const direction = profile[directionName];
|
|
if (!profile.enabled) {
|
|
throw new ExtensionError('transform_profile_disabled', '候选明文网关未启用');
|
|
}
|
|
if (!direction.enabled) {
|
|
throw new ExtensionError('transform_direction_disabled', `候选明文网关未启用 ${directionName} 转换方向`);
|
|
}
|
|
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();
|
|
}
|
|
}
|