mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CryptoAdapterScope, CryptoAdapterToolkit } from './contract';
|
||||
import { cryptoAdapterLabel } from './catalog';
|
||||
import { cryptoJsAdapter } from './cryptojs';
|
||||
import { jsEncryptAdapter } from './jsencrypt';
|
||||
import { webCryptoAdapter } from './webcrypto';
|
||||
import { smCryptoAdapter } from './sm-crypto';
|
||||
import { nodeForgeAdapter } from './node-forge';
|
||||
import { jsrsasignAdapter } from './jsrsasign';
|
||||
import { joseAdapter } from './jose';
|
||||
|
||||
function byteLength(value: unknown): number | undefined {
|
||||
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
|
||||
if (ArrayBuffer.isView(value)) return value.byteLength;
|
||||
if (value instanceof ArrayBuffer) return value.byteLength;
|
||||
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
|
||||
return (value as { sigBytes: number }).sigBytes;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toolkit(): CryptoAdapterToolkit {
|
||||
return {
|
||||
unique: (prefix) => `${prefix}-1`,
|
||||
byteLength,
|
||||
dataType: (value) => typeof value,
|
||||
fingerprint: () => 'v2:opaque-fingerprint',
|
||||
argument: (index, role, value, replaceable, retained, summary) => ({
|
||||
index,
|
||||
role,
|
||||
dataType: typeof value,
|
||||
byteLength: byteLength(value),
|
||||
replaceable,
|
||||
retained,
|
||||
summary,
|
||||
}),
|
||||
collectEvidence: (value, path) => [{
|
||||
path,
|
||||
fingerprint: `fingerprint:${String(value)}`,
|
||||
encoding: 'text',
|
||||
byteLength: byteLength(String(value)) || 0,
|
||||
}],
|
||||
defaultOutputEvidence: () => [],
|
||||
defaultAdaptInput: (value) => value,
|
||||
bytesForInput: (value) => value instanceof Uint8Array ? value : undefined,
|
||||
bytesToBase64: (value) => `base64:${Array.from(value).join(',')}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('page crypto adapters', () => {
|
||||
it('keeps the UI catalog separate and safely falls back for unknown adapter IDs', () => {
|
||||
expect(cryptoAdapterLabel('webcrypto')).toBe('WebCrypto');
|
||||
expect(cryptoAdapterLabel('vendor-suite.v2')).toBe('vendor-suite.v2');
|
||||
});
|
||||
|
||||
it('describes WebCrypto input roles and state without reading key material', () => {
|
||||
const subtlePrototype = { encrypt() { return Promise.resolve(new ArrayBuffer(0)); } };
|
||||
const subtle = Object.create(subtlePrototype) as SubtleCrypto;
|
||||
const operations = webCryptoAdapter.discover({
|
||||
window: { crypto: { subtle } } as unknown as Window,
|
||||
crypto: { subtle } as Crypto,
|
||||
});
|
||||
const encrypt = operations.find((item) => item.operation === 'encrypt');
|
||||
const key = { type: 'secret' } as CryptoKey;
|
||||
const plan = encrypt?.describe(subtle, [
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(12), tagLength: 128 },
|
||||
key,
|
||||
new Uint8Array([1, 2, 3]),
|
||||
], toolkit());
|
||||
|
||||
expect(plan?.crypto).toMatchObject({
|
||||
adapterId: 'webcrypto',
|
||||
providerKind: 'native',
|
||||
family: 'symmetric',
|
||||
operation: 'encrypt',
|
||||
algorithm: 'AES-GCM tag=128 ivBytes=12',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
});
|
||||
expect(plan?.arguments.map((argument) => argument.role)).toEqual(['algorithm', 'key', 'data']);
|
||||
expect(plan?.arguments[2]).toMatchObject({ replaceable: true, retained: true, byteLength: 3 });
|
||||
expect(JSON.stringify(plan)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('describes CryptoJS modes and adapts bytes through the page encoder', () => {
|
||||
const CBC = {};
|
||||
const Pkcs7 = {};
|
||||
const parsed: string[] = [];
|
||||
const cryptoJs = {
|
||||
AES: { encrypt() { return 'cipher'; } },
|
||||
mode: { CBC },
|
||||
pad: { Pkcs7 },
|
||||
enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
|
||||
};
|
||||
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
|
||||
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
|
||||
const plan = encrypt?.describe(cryptoJs.AES, [
|
||||
{ sigBytes: 3 },
|
||||
{ sigBytes: 16 },
|
||||
{ mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
|
||||
], toolkit());
|
||||
|
||||
expect(plan?.crypto).toMatchObject({
|
||||
adapterId: 'cryptojs', family: 'symmetric', operation: 'AES.encrypt',
|
||||
mode: 'CBC', padding: 'Pkcs7', outputEncoding: 'base64',
|
||||
});
|
||||
expect(plan?.arguments[2].summary).toBe('mode=CBC padding=Pkcs7 ivBytes=16');
|
||||
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
|
||||
expect(parsed).toEqual(['base64:4,5,6']);
|
||||
});
|
||||
|
||||
it('retains only bounded JSEncrypt receiver metadata', () => {
|
||||
const prototype = {
|
||||
encrypt() { return 'ciphertext'; },
|
||||
decrypt() { return 'plaintext'; },
|
||||
sign() { return 'signature'; },
|
||||
verify() { return true; },
|
||||
};
|
||||
const instance = {
|
||||
key: {
|
||||
n: { bitLength: () => 2048, toString: () => 'public-modulus' },
|
||||
e: 65_537,
|
||||
},
|
||||
};
|
||||
const encrypt = jsEncryptAdapter.discover({
|
||||
window: { JSEncrypt: { prototype } } as unknown as Window,
|
||||
}).find((item) => item.operation === 'encrypt');
|
||||
const plan = encrypt?.describe(instance, ['plain'], toolkit());
|
||||
|
||||
expect(plan?.crypto).toMatchObject({
|
||||
adapterId: 'jsencrypt', family: 'asymmetric', algorithm: 'RSA',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
|
||||
});
|
||||
expect(plan?.arguments[0]).toMatchObject({ role: 'data', replaceable: true, retained: true });
|
||||
expect(JSON.stringify(plan?.crypto)).not.toContain('public-modulus');
|
||||
});
|
||||
|
||||
it('describes sm-crypto SM2/SM3/SM4 using one bounded contract', () => {
|
||||
const smCrypto = {
|
||||
sm2: {
|
||||
doEncrypt: () => 'cipher',
|
||||
doDecrypt: () => 'plain',
|
||||
doSignature: () => 'signature',
|
||||
doVerifySignature: () => true,
|
||||
},
|
||||
sm3: () => 'digest',
|
||||
sm4: { encrypt: () => 'cipher', decrypt: () => 'plain' },
|
||||
};
|
||||
const operations = smCryptoAdapter.discover({ window: { ...smCrypto } as unknown as Window });
|
||||
const sm4 = operations.find((item) => item.operation === 'sm4.encrypt');
|
||||
const plan = sm4?.describe(smCrypto.sm4, [
|
||||
'plain',
|
||||
'00112233445566778899aabbccddeeff',
|
||||
{ mode: 'cbc', padding: 'pkcs#7', iv: '0102030405060708' },
|
||||
], toolkit());
|
||||
const verify = operations.find((item) => item.operation === 'sm2.verify')?.describe(
|
||||
smCrypto.sm2, ['plain', 'signature', 'public-key', { hash: true }], toolkit(),
|
||||
);
|
||||
|
||||
expect(operations).toHaveLength(7);
|
||||
expect(plan?.crypto).toMatchObject({
|
||||
adapterId: 'sm-crypto', family: 'symmetric', algorithm: 'SM4', mode: 'cbc', padding: 'pkcs#7',
|
||||
state: { model: 'stateless', phase: 'one-shot' },
|
||||
key: { kind: 'secret', bits: 128, fingerprint: 'v2:opaque-fingerprint' },
|
||||
});
|
||||
expect(plan?.arguments[2].summary).toContain('ivBytes=16');
|
||||
expect(verify?.callableKind).toBe('verify');
|
||||
expect(verify?.arguments.map((item) => item.role)).toEqual(['data', 'signature', 'key', 'options']);
|
||||
expect(JSON.stringify(plan?.crypto)).not.toContain('00112233445566778899aabbccddeeff');
|
||||
});
|
||||
|
||||
it('discovers node-forge stateful cipher sessions without treating them as replay-safe one-shot calls', () => {
|
||||
const outputBuffer = {
|
||||
bytes: () => 'cipher-bytes',
|
||||
length: () => 12,
|
||||
getBytes: () => 'cipher-bytes',
|
||||
};
|
||||
const session = {
|
||||
output: outputBuffer,
|
||||
start: () => undefined,
|
||||
update: () => undefined,
|
||||
finish: () => true,
|
||||
};
|
||||
const forge = {
|
||||
cipher: { createCipher: () => session, createDecipher: () => session },
|
||||
hmac: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
|
||||
pki: {
|
||||
publicKeyFromPem: () => ({
|
||||
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
|
||||
encrypt: (value: string) => value, verify: () => true,
|
||||
}),
|
||||
privateKeyFromPem: () => ({
|
||||
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
|
||||
d: {}, decrypt: (value: string) => value, sign: () => 'signature',
|
||||
}),
|
||||
},
|
||||
md: {
|
||||
sha256: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
|
||||
},
|
||||
};
|
||||
const operations = nodeForgeAdapter.discover({ window: { forge } as unknown as Window });
|
||||
const factory = operations.find((item) => item.operation === 'cipher.create.encrypt');
|
||||
const factoryPlan = factory?.describe(forge.cipher, ['AES-CBC', 'secret-key'], toolkit());
|
||||
const sessionOperations = factoryPlan?.discoverResult?.(session) || [];
|
||||
const update = sessionOperations.find((item) => item.operation === 'cipher.encrypt.update');
|
||||
const finish = sessionOperations.find((item) => item.operation === 'cipher.encrypt.finish');
|
||||
const updatePlan = update?.describe(session, [outputBuffer], toolkit());
|
||||
const finishPlan = finish?.describe(session, [], toolkit());
|
||||
|
||||
expect(factoryPlan?.crypto).toMatchObject({
|
||||
adapterId: 'node-forge', family: 'symmetric', algorithm: 'AES-CBC',
|
||||
state: { model: 'session', phase: 'create', correlationId: 'forge-session-1' },
|
||||
});
|
||||
expect(updatePlan?.crypto.state).toMatchObject({ model: 'stream', phase: 'update', correlationId: 'forge-session-1' });
|
||||
expect(updatePlan?.callableKind).toBeUndefined();
|
||||
expect(finishPlan?.outputEvidence?.(true)[0]).toMatchObject({ path: '$receiver.output' });
|
||||
expect(sessionOperations.some((item) => item.operation === 'cipher.encrypt.output.getBytes')).toBe(true);
|
||||
});
|
||||
|
||||
it('turns node-forge RSA key instances into receiver-bound direct operations without exporting PEM', () => {
|
||||
const key = {
|
||||
n: { bitLength: () => 2048, toString: () => 'private-modulus' },
|
||||
e: 65_537,
|
||||
encrypt: (value: string) => `cipher:${value}`,
|
||||
verify: () => true,
|
||||
};
|
||||
const forge = { pki: { publicKeyFromPem: () => key } };
|
||||
const factory = nodeForgeAdapter.discover({ window: { forge } as unknown as Window })
|
||||
.find((item) => item.operation === 'pki.public-key.create');
|
||||
const plan = factory?.describe(forge.pki, ['-----BEGIN PUBLIC KEY-----raw-material'], toolkit());
|
||||
const encrypt = plan?.discoverResult?.(key).find((item) => item.operation === 'rsa.encrypt');
|
||||
const encryptPlan = encrypt?.describe(key, ['plain', 'RSA-OAEP'], toolkit());
|
||||
|
||||
expect(plan?.outputEvidence?.(key)).toEqual([]);
|
||||
expect(encryptPlan?.callableKind).toBe('encrypt');
|
||||
expect(encryptPlan?.crypto).toMatchObject({
|
||||
family: 'asymmetric', algorithm: 'RSA', state: { model: 'receiver', phase: 'one-shot' },
|
||||
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
|
||||
});
|
||||
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('raw-material');
|
||||
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('private-modulus');
|
||||
});
|
||||
|
||||
it('models a jsrsasign constructor session as create, init, update, and final stages', () => {
|
||||
class Signature {
|
||||
constructor(public options: { alg: string }) {}
|
||||
init(_key: unknown) {}
|
||||
updateString(_value: string) {}
|
||||
sign() { return 'deadbeef'; }
|
||||
verify(_signature: string) { return true; }
|
||||
}
|
||||
const JWS = {
|
||||
sign: (_algorithm: string, _header: unknown, payload: unknown) => `jws:${String(payload)}`,
|
||||
verify: () => true,
|
||||
verifyJWT: () => true,
|
||||
getJWKthumbprint: () => 'thumbprint',
|
||||
};
|
||||
const window = {
|
||||
KJUR: { crypto: { Signature }, jws: { JWS } },
|
||||
KEYUTIL: { getKey: () => ({}), getJWK: () => ({ kty: 'RSA' }), getPEM: () => 'pem' },
|
||||
} as unknown as Window;
|
||||
const operations = jsrsasignAdapter.discover({ window });
|
||||
const constructor = operations.find((item) => item.operation === 'Signature.create');
|
||||
const createPlan = constructor?.describe(undefined, [{ alg: 'SHA256withRSA' }], toolkit());
|
||||
const instance = new Signature({ alg: 'SHA256withRSA' });
|
||||
const stages = createPlan?.discoverResult?.(instance) || [];
|
||||
const init = stages.find((item) => item.operation === 'Signature.init')
|
||||
?.describe(instance, ['-----BEGIN PRIVATE KEY-----private-material'], toolkit());
|
||||
const update = stages.find((item) => item.operation === 'Signature.updateString')
|
||||
?.describe(instance, ['canonical-request'], toolkit());
|
||||
const sign = stages.find((item) => item.operation === 'Signature.sign')
|
||||
?.describe(instance, [], toolkit());
|
||||
|
||||
expect(constructor?.invocationMode).toBe('construct');
|
||||
expect(createPlan?.crypto).toMatchObject({
|
||||
adapterId: 'jsrsasign', family: 'signature', algorithm: 'SHA256withRSA',
|
||||
state: { model: 'session', phase: 'create', correlationId: 'jsrsasign-signature-1' },
|
||||
});
|
||||
expect(init?.crypto.state).toMatchObject({ phase: 'init', correlationId: 'jsrsasign-signature-1' });
|
||||
expect(update?.crypto.state).toMatchObject({ phase: 'update', correlationId: 'jsrsasign-signature-1' });
|
||||
expect(sign?.crypto.state).toMatchObject({ phase: 'final', correlationId: 'jsrsasign-signature-1' });
|
||||
expect(sign?.callableKind).toBeUndefined();
|
||||
expect(JSON.stringify(init?.crypto)).not.toContain('private-material');
|
||||
|
||||
const jwsSign = operations.find((item) => item.operation === 'JWS.sign')
|
||||
?.describe(JWS, ['RS256', { alg: 'RS256' }, { account: 'admin' }, 'private-key'], toolkit());
|
||||
expect(jwsSign).toMatchObject({ inputIndex: 2, callableKind: 'sign' });
|
||||
});
|
||||
|
||||
it('models jose builders as async stateful envelopes and keeps key material opaque', () => {
|
||||
class SignJWT {
|
||||
constructor(public payload: unknown) {}
|
||||
setProtectedHeader(_header: unknown) { return this; }
|
||||
setIssuedAt() { return this; }
|
||||
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
|
||||
}
|
||||
class CompactSign {
|
||||
constructor(public payload: Uint8Array) {}
|
||||
setProtectedHeader(_header: unknown) { return this; }
|
||||
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
|
||||
}
|
||||
class CompactEncrypt {
|
||||
constructor(public payload: Uint8Array) {}
|
||||
setProtectedHeader(_header: unknown) { return this; }
|
||||
encrypt(_key: unknown) { return Promise.resolve('compact-jwe'); }
|
||||
}
|
||||
const jose = {
|
||||
SignJWT, CompactSign, CompactEncrypt,
|
||||
compactVerify: async () => ({ payload: new Uint8Array() }),
|
||||
jwtVerify: async () => ({ payload: {} }),
|
||||
compactDecrypt: async () => ({ plaintext: new Uint8Array() }),
|
||||
jwtDecrypt: async () => ({ payload: {} }),
|
||||
importJWK: async () => ({}),
|
||||
exportJWK: async () => ({ kty: 'RSA' }),
|
||||
};
|
||||
const operations = joseAdapter.discover({ window: { jose } as unknown as Window });
|
||||
const constructor = operations.find((item) => item.operation === 'SignJWT.create');
|
||||
const createPlan = constructor?.describe(undefined, [{ account: 'admin' }], toolkit());
|
||||
const instance = new SignJWT({ account: 'admin' });
|
||||
const stages = createPlan?.discoverResult?.(instance) || [];
|
||||
const header = stages.find((item) => item.operation === 'SignJWT.setProtectedHeader')
|
||||
?.describe(instance, [{ alg: 'RS256' }], toolkit());
|
||||
const final = stages.find((item) => item.operation === 'SignJWT.sign')
|
||||
?.describe(instance, [{ type: 'private', algorithm: { name: 'RSA-PSS' }, secret: 'never-export' }], toolkit());
|
||||
|
||||
expect(constructor?.invocationMode).toBe('construct');
|
||||
expect(createPlan?.crypto.state).toMatchObject({ model: 'async-ready', phase: 'create', correlationId: 'jose-session-1' });
|
||||
expect(header?.crypto).toMatchObject({ algorithm: 'RS256', state: { phase: 'update', correlationId: 'jose-session-1' } });
|
||||
expect(final?.crypto).toMatchObject({
|
||||
family: 'signature', algorithm: 'RS256', key: { kind: 'private' },
|
||||
state: { phase: 'final', correlationId: 'jose-session-1' },
|
||||
});
|
||||
expect(stages.find((item) => item.operation === 'SignJWT.sign')?.resultMode).toBe('promise');
|
||||
expect(final?.callableKind).toBeUndefined();
|
||||
expect(JSON.stringify(final?.crypto)).not.toContain('never-export');
|
||||
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CryptoAdapterManifest } from './contract';
|
||||
|
||||
export const webCryptoManifest: CryptoAdapterManifest = {
|
||||
id: 'webcrypto',
|
||||
displayName: 'WebCrypto',
|
||||
providerKind: 'native',
|
||||
dynamic: false,
|
||||
globalPaths: ['crypto.subtle'],
|
||||
};
|
||||
|
||||
export const cryptoJsManifest: CryptoAdapterManifest = {
|
||||
id: 'cryptojs',
|
||||
displayName: 'CryptoJS',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['CryptoJS'],
|
||||
};
|
||||
|
||||
export const jsEncryptManifest: CryptoAdapterManifest = {
|
||||
id: 'jsencrypt',
|
||||
displayName: 'JSEncrypt',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['JSEncrypt.prototype'],
|
||||
};
|
||||
|
||||
export const smCryptoManifest: CryptoAdapterManifest = {
|
||||
id: 'sm-crypto',
|
||||
displayName: 'sm-crypto',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['smCrypto', 'sm2', 'sm3', 'sm4'],
|
||||
};
|
||||
|
||||
export const nodeForgeManifest: CryptoAdapterManifest = {
|
||||
id: 'node-forge',
|
||||
displayName: 'node-forge',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['forge'],
|
||||
};
|
||||
|
||||
export const jsrsasignManifest: CryptoAdapterManifest = {
|
||||
id: 'jsrsasign',
|
||||
displayName: 'jsrsasign',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['KJUR.crypto.Signature', 'KJUR.jws.JWS', 'KEYUTIL'],
|
||||
};
|
||||
|
||||
export const joseManifest: CryptoAdapterManifest = {
|
||||
id: 'jose',
|
||||
displayName: 'jose',
|
||||
providerKind: 'library',
|
||||
dynamic: true,
|
||||
globalPaths: ['jose'],
|
||||
};
|
||||
|
||||
export const CRYPTO_ADAPTER_MANIFESTS: Readonly<Record<string, CryptoAdapterManifest>> = Object.freeze(
|
||||
Object.fromEntries([
|
||||
webCryptoManifest,
|
||||
cryptoJsManifest,
|
||||
jsEncryptManifest,
|
||||
smCryptoManifest,
|
||||
nodeForgeManifest,
|
||||
jsrsasignManifest,
|
||||
joseManifest,
|
||||
].map((manifest) => [manifest.id, Object.freeze(manifest)])),
|
||||
);
|
||||
|
||||
export function cryptoAdapterLabel(adapterId: string): string {
|
||||
return CRYPTO_ADAPTER_MANIFESTS[adapterId]?.displayName || adapterId;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BrowserCryptoFamily } from '@/types/models';
|
||||
import type { CallableOperationKind } from './contract';
|
||||
|
||||
export function cryptoFamily(operation: string, algorithm?: string): BrowserCryptoFamily {
|
||||
const value = `${operation} ${algorithm || ''}`.toLowerCase();
|
||||
if (value.includes('hmac')) return 'mac';
|
||||
if (value.includes('digest') || /\b(?:sha\d*|md5|ripemd|sm3)\b/.test(value)) return 'digest';
|
||||
if (value.includes('derive') || value.includes('pbkdf') || value.includes('evpkdf') || value.includes('kdf')) return 'kdf';
|
||||
if (value.includes('sign') || value.includes('verify')) return 'signature';
|
||||
if (value.includes('rsa') || value.includes('ecies') || value.includes('sm2')) return 'asymmetric';
|
||||
if (value.includes('encrypt') || value.includes('decrypt') || value.includes('wrap') || value.includes('sm4')) return 'symmetric';
|
||||
if (value.includes('key') || value.includes('import') || value.includes('export')) return 'key-management';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function callableOperationKind(operation: string): CallableOperationKind | undefined {
|
||||
const normalized = operation.toLowerCase();
|
||||
if (normalized.includes('decrypt')) return 'decrypt';
|
||||
if (normalized.includes('encrypt')) return 'encrypt';
|
||||
if (normalized.includes('verify')) return 'verify';
|
||||
if (normalized.includes('sign') || normalized.includes('hmac')) return 'sign';
|
||||
if (normalized.includes('digest') || normalized.includes('sha') || normalized.includes('md5') || normalized.includes('sm3')) return 'digest';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function algorithmSummary(
|
||||
value: unknown,
|
||||
byteLength: (input: unknown) => number | undefined,
|
||||
): string | undefined {
|
||||
if (typeof value === 'string') return value.slice(0, 160);
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const algorithm = value as Record<string, unknown>;
|
||||
const parts = [typeof algorithm.name === 'string' ? algorithm.name : 'unknown'];
|
||||
if (typeof algorithm.namedCurve === 'string') parts.push(`curve=${algorithm.namedCurve}`);
|
||||
if (typeof algorithm.length === 'number') parts.push(`length=${algorithm.length}`);
|
||||
if (typeof algorithm.tagLength === 'number') parts.push(`tag=${algorithm.tagLength}`);
|
||||
const hash = algorithm.hash;
|
||||
if (typeof hash === 'string') parts.push(`hash=${hash}`);
|
||||
else if (hash && typeof hash === 'object' && typeof (hash as { name?: unknown }).name === 'string') {
|
||||
parts.push(`hash=${(hash as { name: string }).name}`);
|
||||
}
|
||||
if (algorithm.iv !== undefined) parts.push(`ivBytes=${byteLength(algorithm.iv) || 0}`);
|
||||
if (algorithm.salt !== undefined) parts.push(`saltBytes=${byteLength(algorithm.salt) || 0}`);
|
||||
return parts.join(' ').slice(0, 240);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
BrowserCryptoProviderKind,
|
||||
BrowserPageCallableValueEncoding,
|
||||
BrowserRecordingCallArgument,
|
||||
BrowserRecordingCrypto,
|
||||
BrowserRecordingValueEvidence,
|
||||
} from '@/types/models';
|
||||
|
||||
export type CallableOperationKind = 'encrypt' | 'decrypt' | 'sign' | 'verify' | 'digest';
|
||||
|
||||
export interface CryptoAdapterManifest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
providerKind: BrowserCryptoProviderKind;
|
||||
dynamic: boolean;
|
||||
globalPaths: string[];
|
||||
}
|
||||
|
||||
export interface CryptoAdapterScope {
|
||||
window: Window;
|
||||
crypto?: Crypto;
|
||||
}
|
||||
|
||||
export interface CryptoAdapterToolkit {
|
||||
unique(prefix: string): string;
|
||||
byteLength(value: unknown): number | undefined;
|
||||
dataType(value: unknown): string;
|
||||
fingerprint(value: string): string;
|
||||
argument(
|
||||
index: number,
|
||||
role: BrowserRecordingCallArgument['role'],
|
||||
value: unknown,
|
||||
replaceable: boolean,
|
||||
retained: boolean,
|
||||
summary?: string,
|
||||
): BrowserRecordingCallArgument;
|
||||
collectEvidence(value: unknown, path: string): BrowserRecordingValueEvidence[];
|
||||
defaultOutputEvidence(value: unknown): BrowserRecordingValueEvidence[];
|
||||
defaultAdaptInput(value: unknown, originalInput: unknown): unknown;
|
||||
bytesForInput(value: unknown): Uint8Array | undefined;
|
||||
bytesToBase64(value: Uint8Array): string;
|
||||
}
|
||||
|
||||
export interface CryptoAdapterInvocationPlan {
|
||||
crypto: BrowserRecordingCrypto;
|
||||
inputIndex: number;
|
||||
arguments: BrowserRecordingCallArgument[];
|
||||
callableKind?: CallableOperationKind;
|
||||
outputEncoding?: BrowserPageCallableValueEncoding;
|
||||
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
||||
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
||||
outputError?(value: unknown): string | undefined;
|
||||
adaptInput?(value: unknown): unknown;
|
||||
discoverResult?(value: unknown): CryptoAdapterOperation[];
|
||||
}
|
||||
|
||||
export interface CryptoAdapterOperation {
|
||||
id: string;
|
||||
operation: string;
|
||||
owner: Record<string, unknown>;
|
||||
key: string;
|
||||
invocationMode?: 'call' | 'construct';
|
||||
resultMode: 'sync' | 'promise';
|
||||
describe(thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan;
|
||||
createWrapper(
|
||||
original: Function,
|
||||
invoke: (thisArg: unknown, args: unknown[]) => unknown,
|
||||
): Function;
|
||||
}
|
||||
|
||||
export interface PageCryptoAdapter {
|
||||
manifest: CryptoAdapterManifest;
|
||||
discover(scope: CryptoAdapterScope): CryptoAdapterOperation[];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { callableOperationKind, cryptoFamily } from './common';
|
||||
import type {
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterScope,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { cryptoJsManifest } from './catalog';
|
||||
|
||||
const PATHS = [
|
||||
'AES.encrypt', 'AES.decrypt', 'DES.encrypt', 'DES.decrypt', 'TripleDES.encrypt', 'TripleDES.decrypt',
|
||||
'RC4.encrypt', 'RC4.decrypt', 'Rabbit.encrypt', 'Rabbit.decrypt', 'MD5', 'SHA1', 'SHA224', 'SHA256',
|
||||
'SHA384', 'SHA512', 'SHA3', 'RIPEMD160', 'HmacMD5', 'HmacSHA1', 'HmacSHA224', 'HmacSHA256',
|
||||
'HmacSHA384', 'HmacSHA512', 'PBKDF2', 'EvpKDF',
|
||||
];
|
||||
|
||||
function ownValue(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function memberName(cryptoJs: Record<string, unknown>, group: 'mode' | 'pad', value: unknown): string | undefined {
|
||||
const members = cryptoJs[group];
|
||||
if (!members || typeof members !== 'object') return undefined;
|
||||
try {
|
||||
return Object.entries(members as Record<string, unknown>).find(([, candidate]) => candidate === value)?.[0]?.slice(0, 80);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function optionsMetadata(
|
||||
cryptoJs: Record<string, unknown>,
|
||||
value: unknown,
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): { summary?: string; mode?: string; padding?: string } {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
const mode = memberName(cryptoJs, 'mode', ownValue(value, 'mode'));
|
||||
const padding = memberName(cryptoJs, 'pad', ownValue(value, 'padding'));
|
||||
const iv = ownValue(value, 'iv');
|
||||
const parts: string[] = [];
|
||||
if (mode) parts.push(`mode=${mode}`);
|
||||
if (padding) parts.push(`padding=${padding}`);
|
||||
if (iv !== undefined) parts.push(`ivBytes=${toolkit.byteLength(iv) || 0}`);
|
||||
return { summary: parts.length ? parts.join(' ').slice(0, 240) : undefined, mode, padding };
|
||||
}
|
||||
|
||||
function describe(
|
||||
scope: CryptoAdapterScope,
|
||||
path: string,
|
||||
args: unknown[],
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): CryptoAdapterInvocationPlan {
|
||||
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS || {};
|
||||
const normalized = path.toLowerCase();
|
||||
const encrypting = normalized.includes('encrypt');
|
||||
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
|
||||
? optionsMetadata(cryptoJs, args[2], toolkit)
|
||||
: {};
|
||||
let roles: Array<'data' | 'key' | 'salt' | 'options' | 'unknown'> = ['data'];
|
||||
if (normalized.includes('hmac')) roles = ['data', 'key'];
|
||||
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
|
||||
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
|
||||
const callableKind = callableOperationKind(path);
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: cryptoJsManifest.id,
|
||||
providerKind: cryptoJsManifest.providerKind,
|
||||
family: cryptoFamily(path, path),
|
||||
operation: path,
|
||||
algorithm: path,
|
||||
mode: options.mode,
|
||||
padding: options.padding,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: encrypting ? 'base64' : 'auto',
|
||||
state: { model: 'stateless', phase: 'one-shot' },
|
||||
},
|
||||
inputIndex: 0,
|
||||
callableKind,
|
||||
outputEncoding: encrypting ? 'base64' : 'auto',
|
||||
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
|
||||
index,
|
||||
roles[index] || 'unknown',
|
||||
value,
|
||||
index === 0,
|
||||
Boolean(callableKind),
|
||||
roles[index] === 'options' ? options.summary : undefined,
|
||||
)),
|
||||
outputEvidence(value) {
|
||||
const output = toolkit.defaultOutputEvidence(value);
|
||||
if (!value || (typeof value !== 'object' && typeof value !== 'function') || output.length >= 48) return output;
|
||||
try {
|
||||
const toString = (value as { toString?: unknown }).toString;
|
||||
if (typeof toString !== 'function') return output;
|
||||
const text = Reflect.apply(toString, value, []);
|
||||
if (typeof text !== 'string' || !text || text === '[object Object]') return output;
|
||||
const extra = toolkit.collectEvidence(text, '$output:string')[0];
|
||||
if (extra && !output.some((item) => item.path === extra.path && item.fingerprint === extra.fingerprint)) output.push(extra);
|
||||
} catch {
|
||||
// Compatible CryptoJS result objects are best-effort evidence only.
|
||||
}
|
||||
return output.slice(0, 48);
|
||||
},
|
||||
adaptInput(value) {
|
||||
const originalInput = args[0];
|
||||
if (originalInput && typeof originalInput === 'object'
|
||||
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
|
||||
const bytes = toolkit.bytesForInput(value);
|
||||
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
|
||||
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
|
||||
}
|
||||
return toolkit.defaultAdaptInput(value, originalInput);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const cryptoJsAdapter: PageCryptoAdapter = {
|
||||
manifest: cryptoJsManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS;
|
||||
if (!cryptoJs) return [];
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
for (const path of PATHS) {
|
||||
const segments = path.split('.');
|
||||
let owner: Record<string, unknown> | undefined = cryptoJs;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const next = owner?.[segment];
|
||||
if (!next || (typeof next !== 'object' && typeof next !== 'function')) {
|
||||
owner = undefined;
|
||||
break;
|
||||
}
|
||||
owner = next as Record<string, unknown>;
|
||||
}
|
||||
if (!owner) continue;
|
||||
output.push({
|
||||
id: `cryptojs.${path}`,
|
||||
operation: path,
|
||||
owner,
|
||||
key: segments.at(-1)!,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => describe(scope, path, args, toolkit),
|
||||
createWrapper: (_original, invoke) => function recordedCryptoJs(this: unknown, ...args: unknown[]) {
|
||||
return invoke(this, args);
|
||||
},
|
||||
});
|
||||
}
|
||||
return output;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PageCryptoAdapter } from './contract';
|
||||
import { cryptoJsAdapter } from './cryptojs';
|
||||
import { jsEncryptAdapter } from './jsencrypt';
|
||||
import { webCryptoAdapter } from './webcrypto';
|
||||
import { smCryptoAdapter } from './sm-crypto';
|
||||
import { nodeForgeAdapter } from './node-forge';
|
||||
import { jsrsasignAdapter } from './jsrsasign';
|
||||
import { joseAdapter } from './jose';
|
||||
|
||||
export const PAGE_CRYPTO_ADAPTERS: PageCryptoAdapter[] = [
|
||||
webCryptoAdapter,
|
||||
cryptoJsAdapter,
|
||||
jsEncryptAdapter,
|
||||
smCryptoAdapter,
|
||||
nodeForgeAdapter,
|
||||
jsrsasignAdapter,
|
||||
joseAdapter,
|
||||
];
|
||||
|
||||
export type {
|
||||
CallableOperationKind,
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterManifest,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterScope,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
|
||||
export { createCryptoAdapterRuntime } from './registry';
|
||||
export type { CryptoAdapterRuntime, CryptoAdapterRuntimeHost } from './registry';
|
||||
export { CRYPTO_ADAPTER_MANIFESTS, cryptoAdapterLabel } from './catalog';
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { BrowserRecordingCrypto } from '@/types/models';
|
||||
import type {
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { joseManifest } from './catalog';
|
||||
|
||||
type JoseFamily = 'signature' | 'asymmetric';
|
||||
|
||||
interface JoseBuilderDefinition {
|
||||
key: 'SignJWT' | 'CompactSign' | 'CompactEncrypt';
|
||||
family: JoseFamily;
|
||||
finalMethod: 'sign' | 'encrypt';
|
||||
setters: string[];
|
||||
}
|
||||
|
||||
const BUILDERS: JoseBuilderDefinition[] = [
|
||||
{
|
||||
key: 'SignJWT', family: 'signature', finalMethod: 'sign',
|
||||
setters: ['setProtectedHeader', 'setIssuer', 'setSubject', 'setAudience', 'setJti', 'setNotBefore', 'setExpirationTime', 'setIssuedAt'],
|
||||
},
|
||||
{
|
||||
key: 'CompactSign', family: 'signature', finalMethod: 'sign',
|
||||
setters: ['setProtectedHeader'],
|
||||
},
|
||||
{
|
||||
key: 'CompactEncrypt', family: 'asymmetric', finalMethod: 'encrypt',
|
||||
setters: ['setProtectedHeader', 'setKeyManagementParameters', 'setContentEncryptionKey', 'setInitializationVector'],
|
||||
},
|
||||
];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && (typeof value === 'object' || typeof value === 'function')
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function method(owner: Record<string, unknown> | undefined, key: string): boolean {
|
||||
try { return Boolean(owner && typeof owner[key] === 'function'); } catch { return false; }
|
||||
}
|
||||
|
||||
function callWrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
|
||||
return function recordedJose(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
}
|
||||
|
||||
function constructorWrapper(
|
||||
original: Function,
|
||||
invoke: (thisArg: unknown, args: unknown[]) => unknown,
|
||||
): Function {
|
||||
return new Proxy(original, {
|
||||
apply(_target, thisArg, args) { return invoke(thisArg, args); },
|
||||
construct(_target, args) { return invoke(undefined, args) as object; },
|
||||
});
|
||||
}
|
||||
|
||||
function keyMetadata(
|
||||
value: unknown,
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
preferredKind: NonNullable<BrowserRecordingCrypto['key']>['kind'],
|
||||
): BrowserRecordingCrypto['key'] {
|
||||
const key = record(value);
|
||||
if (!key) return { kind: preferredKind };
|
||||
let kind = preferredKind;
|
||||
let bits: number | undefined;
|
||||
const parts: string[] = [];
|
||||
try {
|
||||
if (key.type === 'private' || key.type === 'public' || key.type === 'secret') kind = key.type;
|
||||
const algorithm = record(key.algorithm);
|
||||
if (typeof algorithm?.name === 'string') parts.push(`name=${algorithm.name}`);
|
||||
if (typeof algorithm?.namedCurve === 'string') parts.push(`crv=${algorithm.namedCurve}`);
|
||||
if (Number.isSafeInteger(algorithm?.length)) bits = Number(algorithm?.length);
|
||||
for (const field of ['kty', 'crv', 'x', 'y', 'n', 'e', 'kid', 'use', 'alg']) {
|
||||
const item = key[field];
|
||||
if (typeof item === 'string' || typeof item === 'number') parts.push(`${field}=${String(item)}`);
|
||||
}
|
||||
} catch { /* CryptoKey and proxy metadata are best effort. */ }
|
||||
return { kind, bits, fingerprint: parts.length ? toolkit.fingerprint(parts.join('&')) : undefined };
|
||||
}
|
||||
|
||||
function algorithmFromHeader(value: unknown): string | undefined {
|
||||
const header = record(value);
|
||||
if (!header) return undefined;
|
||||
const parts: string[] = [];
|
||||
try {
|
||||
if (typeof header.alg === 'string') parts.push(header.alg);
|
||||
if (typeof header.enc === 'string') parts.push(`enc=${header.enc}`);
|
||||
if (typeof header.zip === 'string') parts.push(`zip=${header.zip}`);
|
||||
} catch { return undefined; }
|
||||
return parts.length ? parts.join(' ').slice(0, 240) : undefined;
|
||||
}
|
||||
|
||||
function crypto(
|
||||
definition: JoseBuilderDefinition,
|
||||
operation: string,
|
||||
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
|
||||
correlationId: string,
|
||||
algorithm?: string,
|
||||
key?: BrowserRecordingCrypto['key'],
|
||||
): BrowserRecordingCrypto {
|
||||
return {
|
||||
adapterId: joseManifest.id,
|
||||
providerKind: joseManifest.providerKind,
|
||||
family: definition.family,
|
||||
operation,
|
||||
algorithm,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: 'auto',
|
||||
state: { model: 'async-ready', phase, correlationId },
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
function builderOperations(
|
||||
value: unknown,
|
||||
definition: JoseBuilderDefinition,
|
||||
correlationId: string,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = {};
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
for (const setter of definition.setters) {
|
||||
if (!method(owner, setter)) continue;
|
||||
output.push({
|
||||
id: `jose.${correlationId}.${definition.key}.${setter}`,
|
||||
operation: `${definition.key}.${setter}`,
|
||||
owner,
|
||||
key: setter,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
if (setter === 'setProtectedHeader') context.algorithm = algorithmFromHeader(args[0]) || context.algorithm;
|
||||
return {
|
||||
crypto: crypto(definition, `${definition.key}.${setter}`, 'update', correlationId, context.algorithm, context.key),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'options' : 'unknown', argument, false, false)),
|
||||
outputEvidence: () => [],
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
}
|
||||
if (method(owner, definition.finalMethod)) output.push({
|
||||
id: `jose.${correlationId}.${definition.key}.${definition.finalMethod}`,
|
||||
operation: `${definition.key}.${definition.finalMethod}`,
|
||||
owner,
|
||||
key: definition.finalMethod,
|
||||
resultMode: 'promise',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
context.key = keyMetadata(args[0], toolkit, definition.finalMethod === 'sign' ? 'private' : 'unknown');
|
||||
return {
|
||||
crypto: crypto(definition, `${definition.key}.${definition.finalMethod}`, 'final', correlationId, context.algorithm, context.key),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'key' : 'options', argument, false, false)),
|
||||
outputError: (result) => result === false || result === null ? `${definition.key}.${definition.finalMethod} returned no result` : undefined,
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function builderConstructor(
|
||||
root: Record<string, unknown>,
|
||||
definition: JoseBuilderDefinition,
|
||||
): CryptoAdapterOperation | undefined {
|
||||
if (!method(root, definition.key)) return undefined;
|
||||
return {
|
||||
id: `jose.${definition.key}.constructor`,
|
||||
operation: `${definition.key}.create`,
|
||||
owner: root,
|
||||
key: definition.key,
|
||||
invocationMode: 'construct',
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
const correlationId = toolkit.unique('jose-session');
|
||||
return {
|
||||
crypto: crypto(definition, `${definition.key}.create`, 'create', correlationId),
|
||||
inputIndex: 0,
|
||||
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
|
||||
outputEvidence: () => [],
|
||||
discoverResult: (value) => builderOperations(value, definition, correlationId),
|
||||
};
|
||||
},
|
||||
createWrapper: constructorWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
interface AsyncOperationDefinition {
|
||||
key: string;
|
||||
operation: string;
|
||||
family: BrowserRecordingCrypto['family'];
|
||||
roles: Array<'data' | 'key' | 'options'>;
|
||||
keyKind: NonNullable<BrowserRecordingCrypto['key']>['kind'];
|
||||
}
|
||||
|
||||
const ASYNC_OPERATIONS: AsyncOperationDefinition[] = [
|
||||
{ key: 'compactVerify', operation: 'CompactVerify.verify', family: 'signature', roles: ['data', 'key', 'options'], keyKind: 'public' },
|
||||
{ key: 'jwtVerify', operation: 'JWT.verify', family: 'signature', roles: ['data', 'key', 'options'], keyKind: 'public' },
|
||||
{ key: 'compactDecrypt', operation: 'CompactDecrypt.decrypt', family: 'asymmetric', roles: ['data', 'key', 'options'], keyKind: 'private' },
|
||||
{ key: 'jwtDecrypt', operation: 'JWT.decrypt', family: 'asymmetric', roles: ['data', 'key', 'options'], keyKind: 'private' },
|
||||
{ key: 'importJWK', operation: 'JWK.import', family: 'key-management', roles: ['key', 'options', 'options'], keyKind: 'unknown' },
|
||||
{ key: 'exportJWK', operation: 'JWK.export', family: 'key-management', roles: ['key'], keyKind: 'unknown' },
|
||||
];
|
||||
|
||||
function asyncOperation(root: Record<string, unknown>, definition: AsyncOperationDefinition): CryptoAdapterOperation | undefined {
|
||||
if (!method(root, definition.key)) return undefined;
|
||||
return {
|
||||
id: `jose.${definition.key}`,
|
||||
operation: definition.operation,
|
||||
owner: root,
|
||||
key: definition.key,
|
||||
resultMode: 'promise',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
const keyIndex = definition.roles.indexOf('key');
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: joseManifest.id,
|
||||
providerKind: joseManifest.providerKind,
|
||||
family: definition.family,
|
||||
operation: definition.operation,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: 'auto',
|
||||
state: { model: 'async-ready', phase: 'one-shot' },
|
||||
key: keyIndex >= 0 ? keyMetadata(args[keyIndex], toolkit, definition.keyKind) : undefined,
|
||||
},
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 6).map((argument, index) => toolkit.argument(index, definition.roles[index] || 'unknown', argument, false, false)),
|
||||
outputEvidence: definition.family === 'key-management' ? () => [] : undefined,
|
||||
outputError: (result) => result === false || result === null ? `${definition.operation} returned no result` : undefined,
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
};
|
||||
}
|
||||
|
||||
export const joseAdapter: PageCryptoAdapter = {
|
||||
manifest: joseManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const root = record((scope.window as unknown as { jose?: unknown }).jose);
|
||||
if (!root) return [];
|
||||
return [
|
||||
...BUILDERS.map((definition) => builderConstructor(root, definition)),
|
||||
...ASYNC_OPERATIONS.map((definition) => asyncOperation(root, definition)),
|
||||
].filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { BrowserRecordingCrypto } from '@/types/models';
|
||||
import type {
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { jsEncryptManifest } from './catalog';
|
||||
|
||||
type JSEncryptOperation = 'encrypt' | 'decrypt' | 'sign' | 'verify';
|
||||
|
||||
function keyMetadata(instance: unknown, toolkit: CryptoAdapterToolkit): BrowserRecordingCrypto['key'] {
|
||||
if (!instance || typeof instance !== 'object') return { kind: 'unknown' };
|
||||
const record = instance as Record<string, unknown>;
|
||||
const key = record.key && typeof record.key === 'object' ? record.key as Record<string, unknown> : undefined;
|
||||
let bits: number | undefined;
|
||||
try {
|
||||
const modulus = key?.n as { bitLength?: unknown } | undefined;
|
||||
if (typeof modulus?.bitLength === 'function') {
|
||||
const value = Reflect.apply(modulus.bitLength as Function, modulus, []);
|
||||
if (Number.isSafeInteger(value) && value >= 256 && value <= 32_768) bits = Number(value);
|
||||
}
|
||||
} catch {
|
||||
// Key metadata must never affect the page operation.
|
||||
}
|
||||
let fingerprint: string | undefined;
|
||||
try {
|
||||
const modulus = key?.n as { toString?: unknown } | undefined;
|
||||
if (typeof modulus?.toString === 'function') {
|
||||
const publicMaterial = `${Reflect.apply(modulus.toString as Function, modulus, [16])}:${String(key?.e || '')}`;
|
||||
if (publicMaterial.length > 1) fingerprint = toolkit.fingerprint(publicMaterial);
|
||||
}
|
||||
} catch {
|
||||
// Compatible implementations may not expose bounded public metadata.
|
||||
}
|
||||
return {
|
||||
kind: key?.d ? 'private' : key?.n ? 'public' : 'unknown',
|
||||
bits,
|
||||
fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function describe(
|
||||
operation: JSEncryptOperation,
|
||||
instance: unknown,
|
||||
args: unknown[],
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): CryptoAdapterInvocationPlan {
|
||||
const encrypting = operation === 'encrypt' || operation === 'sign';
|
||||
const roles: Array<'data' | 'signature' | 'algorithm' | 'options' | 'unknown'> = operation === 'verify'
|
||||
? ['data', 'signature', 'algorithm']
|
||||
: operation === 'sign'
|
||||
? ['data', 'algorithm', 'options']
|
||||
: ['data'];
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: jsEncryptManifest.id,
|
||||
providerKind: jsEncryptManifest.providerKind,
|
||||
family: operation === 'sign' || operation === 'verify' ? 'signature' : 'asymmetric',
|
||||
operation,
|
||||
algorithm: 'RSA',
|
||||
padding: operation === 'encrypt' || operation === 'decrypt' ? 'PKCS1-v1_5' : 'PKCS1-v1_5-signature',
|
||||
inputEncoding: operation === 'decrypt' ? 'base64' : 'utf8',
|
||||
outputEncoding: encrypting ? 'base64' : operation === 'decrypt' ? 'utf8' : 'auto',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
key: keyMetadata(instance, toolkit),
|
||||
},
|
||||
inputIndex: 0,
|
||||
callableKind: operation,
|
||||
outputEncoding: encrypting ? 'base64' : operation === 'decrypt' ? 'utf8' : 'auto',
|
||||
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
|
||||
index,
|
||||
roles[index] || 'unknown',
|
||||
value,
|
||||
index === 0,
|
||||
true,
|
||||
typeof value === 'function' ? value.name || 'function' : undefined,
|
||||
)),
|
||||
outputError: (value) => value === false || value === null ? 'JSEncrypt returned no result' : undefined,
|
||||
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[0]),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
operation: JSEncryptOperation,
|
||||
invoke: (thisArg: unknown, args: unknown[]) => unknown,
|
||||
): Function {
|
||||
switch (operation) {
|
||||
case 'encrypt': return function recordedJSEncryptEncrypt(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'decrypt': return function recordedJSEncryptDecrypt(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'sign': return function recordedJSEncryptSign(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'verify': return function recordedJSEncryptVerify(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
}
|
||||
}
|
||||
|
||||
export const jsEncryptAdapter: PageCryptoAdapter = {
|
||||
manifest: jsEncryptManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const constructor = (scope.window as unknown as { JSEncrypt?: { prototype?: Record<string, unknown> } }).JSEncrypt;
|
||||
const owner = constructor?.prototype;
|
||||
if (!owner) return [];
|
||||
return (['encrypt', 'decrypt', 'sign', 'verify'] as JSEncryptOperation[]).map((operation) => ({
|
||||
id: `jsencrypt.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key: operation,
|
||||
resultMode: 'sync',
|
||||
describe: (thisArg, args, toolkit) => describe(operation, thisArg, args, toolkit),
|
||||
createWrapper: (_original, invoke) => wrapper(operation, invoke),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { BrowserRecordingCrypto } from '@/types/models';
|
||||
import type {
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { jsrsasignManifest } from './catalog';
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && (typeof value === 'object' || typeof value === 'function')
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function method(owner: Record<string, unknown> | undefined, key: string): boolean {
|
||||
try { return Boolean(owner && typeof owner[key] === 'function'); } catch { return false; }
|
||||
}
|
||||
|
||||
function stringProperty(value: unknown, key: string): string | undefined {
|
||||
const input = record(value);
|
||||
try { return typeof input?.[key] === 'string' ? String(input[key]).slice(0, 160) : undefined; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function callWrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
|
||||
return function recordedJsrsasign(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
}
|
||||
|
||||
function constructorWrapper(
|
||||
original: Function,
|
||||
invoke: (thisArg: unknown, args: unknown[]) => unknown,
|
||||
): Function {
|
||||
return new Proxy(original, {
|
||||
apply(_target, thisArg, args) { return invoke(thisArg, args); },
|
||||
construct(_target, args) { return invoke(undefined, args) as object; },
|
||||
});
|
||||
}
|
||||
|
||||
function publicKeyMaterial(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
const key = record(value);
|
||||
if (!key) return undefined;
|
||||
const fields = ['kty', 'crv', 'x', 'y', 'n', 'e', 'kid', 'use', 'alg'];
|
||||
const parts: string[] = [];
|
||||
for (const field of fields) {
|
||||
try {
|
||||
const item = key[field];
|
||||
if (typeof item === 'string' || typeof item === 'number') parts.push(`${field}=${String(item)}`);
|
||||
} catch { /* Proxy-backed key metadata is optional. */ }
|
||||
}
|
||||
try {
|
||||
const modulus = record(key.n);
|
||||
if (typeof modulus?.toString === 'function') parts.push(`n=${Reflect.apply(modulus.toString as Function, key.n, [16])}`);
|
||||
if (key.e !== undefined) parts.push(`e=${String(key.e)}`);
|
||||
} catch { /* Big integer internals differ by release. */ }
|
||||
return parts.length ? parts.join('&') : undefined;
|
||||
}
|
||||
|
||||
function keyMetadata(
|
||||
value: unknown,
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
preferredKind: NonNullable<BrowserRecordingCrypto['key']>['kind'] = 'unknown',
|
||||
): BrowserRecordingCrypto['key'] {
|
||||
const text = typeof value === 'string' ? value : '';
|
||||
const kind = /PRIVATE KEY/.test(text) || Boolean(record(value)?.d) ? 'private'
|
||||
: /PUBLIC KEY|CERTIFICATE/.test(text) ? 'public'
|
||||
: preferredKind;
|
||||
let bits: number | undefined;
|
||||
try {
|
||||
const modulus = record(record(value)?.n);
|
||||
const result = typeof modulus?.bitLength === 'function'
|
||||
? Reflect.apply(modulus.bitLength as Function, record(value)?.n, [])
|
||||
: undefined;
|
||||
if (Number.isSafeInteger(result) && Number(result) >= 256 && Number(result) <= 32_768) bits = Number(result);
|
||||
} catch { /* Key size is optional. */ }
|
||||
const material = publicKeyMaterial(value);
|
||||
return { kind, bits, fingerprint: material ? toolkit.fingerprint(material) : undefined };
|
||||
}
|
||||
|
||||
function signatureCrypto(
|
||||
operation: string,
|
||||
algorithm: string | undefined,
|
||||
correlationId: string,
|
||||
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
|
||||
key?: BrowserRecordingCrypto['key'],
|
||||
): BrowserRecordingCrypto {
|
||||
return {
|
||||
adapterId: jsrsasignManifest.id,
|
||||
providerKind: jsrsasignManifest.providerKind,
|
||||
family: 'signature',
|
||||
operation,
|
||||
algorithm,
|
||||
inputEncoding: operation.toLowerCase().includes('hex') ? 'hex' : 'utf8',
|
||||
outputEncoding: 'hex',
|
||||
state: { model: 'session', phase, correlationId },
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
function signatureSessionOperations(
|
||||
value: unknown,
|
||||
correlationId: string,
|
||||
initialAlgorithm: string | undefined,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = { algorithm: initialAlgorithm };
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
|
||||
for (const key of ['init', 'initSign', 'initVerifyByPublicKey', 'initVerifyByCertificatePEM']) {
|
||||
if (!method(owner, key)) continue;
|
||||
output.push({
|
||||
id: `jsrsasign.${correlationId}.Signature.${key}`,
|
||||
operation: `Signature.${key}`,
|
||||
owner,
|
||||
key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
context.key = keyMetadata(args[0], toolkit, key.includes('Verify') ? 'public' : 'unknown');
|
||||
return {
|
||||
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'init', context.key),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
|
||||
index, index === 0 ? 'key' : 'options', argument, false, false,
|
||||
)),
|
||||
outputEvidence: () => [],
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
}
|
||||
|
||||
if (method(owner, 'setAlgAndProvider')) output.push({
|
||||
id: `jsrsasign.${correlationId}.Signature.setAlgAndProvider`,
|
||||
operation: 'Signature.setAlgAndProvider',
|
||||
owner,
|
||||
key: 'setAlgAndProvider',
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
if (typeof args[0] === 'string') context.algorithm = args[0].slice(0, 160);
|
||||
return {
|
||||
crypto: signatureCrypto('Signature.setAlgAndProvider', context.algorithm, correlationId, 'init', context.key),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 2).map((argument, index) => toolkit.argument(index, 'algorithm', argument, false, false)),
|
||||
outputEvidence: () => [],
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
|
||||
for (const key of ['updateString', 'updateHex']) {
|
||||
if (!method(owner, key)) continue;
|
||||
output.push({
|
||||
id: `jsrsasign.${correlationId}.Signature.${key}`,
|
||||
operation: `Signature.${key}`,
|
||||
owner,
|
||||
key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => ({
|
||||
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'update', context.key),
|
||||
inputIndex: 0,
|
||||
arguments: args.slice(0, 2).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
|
||||
}),
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ['sign', 'signString', 'signHex', 'verify']) {
|
||||
if (!method(owner, key)) continue;
|
||||
const verify = key === 'verify';
|
||||
const inputIndex = key === 'signString' || key === 'signHex' ? 0 : -1;
|
||||
output.push({
|
||||
id: `jsrsasign.${correlationId}.Signature.${key}`,
|
||||
operation: `Signature.${key}`,
|
||||
owner,
|
||||
key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => ({
|
||||
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'final', context.key),
|
||||
inputIndex,
|
||||
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
|
||||
index, verify && index === 0 ? 'signature' : index === inputIndex ? 'data' : 'options', argument, false, false,
|
||||
)),
|
||||
outputError: (result) => verify && result === false ? 'jsrsasign signature verification failed' : undefined,
|
||||
}),
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function signatureConstructor(root: Record<string, unknown>): CryptoAdapterOperation | undefined {
|
||||
const crypto = record(root.crypto);
|
||||
if (!method(crypto, 'Signature')) return undefined;
|
||||
return {
|
||||
id: 'jsrsasign.Signature.constructor',
|
||||
operation: 'Signature.create',
|
||||
owner: crypto!,
|
||||
key: 'Signature',
|
||||
invocationMode: 'construct',
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit): CryptoAdapterInvocationPlan => {
|
||||
const correlationId = toolkit.unique('jsrsasign-signature');
|
||||
const algorithm = stringProperty(args[0], 'alg') || (typeof args[0] === 'string' ? args[0].slice(0, 160) : undefined);
|
||||
return {
|
||||
crypto: signatureCrypto('Signature.create', algorithm, correlationId, 'create'),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(index, index === 0 ? 'options' : 'unknown', argument, false, false)),
|
||||
outputEvidence: () => [],
|
||||
discoverResult: (value) => signatureSessionOperations(value, correlationId, algorithm),
|
||||
};
|
||||
},
|
||||
createWrapper: constructorWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
function oneShotOperation(
|
||||
owner: Record<string, unknown>,
|
||||
key: string,
|
||||
operation: string,
|
||||
family: BrowserRecordingCrypto['family'],
|
||||
roles: Array<'algorithm' | 'options' | 'data' | 'key' | 'signature'>,
|
||||
inputIndex: number,
|
||||
callable: 'sign' | undefined,
|
||||
): CryptoAdapterOperation | undefined {
|
||||
if (!method(owner, key)) return undefined;
|
||||
return {
|
||||
id: `jsrsasign.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
const algorithmIndex = roles.indexOf('algorithm');
|
||||
const keyIndex = roles.indexOf('key');
|
||||
const algorithm = algorithmIndex >= 0 && typeof args[algorithmIndex] === 'string' ? args[algorithmIndex].slice(0, 160) : undefined;
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: jsrsasignManifest.id,
|
||||
providerKind: jsrsasignManifest.providerKind,
|
||||
family,
|
||||
operation,
|
||||
algorithm,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: operation.includes('sign') ? 'auto' : undefined,
|
||||
state: { model: 'stateless', phase: 'one-shot' },
|
||||
key: keyIndex >= 0 ? keyMetadata(args[keyIndex], toolkit, operation.includes('sign') ? 'private' : 'public') : undefined,
|
||||
},
|
||||
inputIndex,
|
||||
callableKind: callable,
|
||||
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
|
||||
index, roles[index] || 'unknown', argument, index === inputIndex, Boolean(callable),
|
||||
)),
|
||||
outputEvidence: family === 'key-management' ? () => [] : undefined,
|
||||
outputError: (result) => result === false || result === null ? `${operation} returned no result` : undefined,
|
||||
adaptInput: inputIndex >= 0 ? (value) => toolkit.defaultAdaptInput(value, args[inputIndex]) : undefined,
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => callWrapper(invoke),
|
||||
};
|
||||
}
|
||||
|
||||
export const jsrsasignAdapter: PageCryptoAdapter = {
|
||||
manifest: jsrsasignManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const globals = scope.window as unknown as { KJUR?: Record<string, unknown>; KEYUTIL?: Record<string, unknown> };
|
||||
const kjur = globals.KJUR;
|
||||
if (!kjur && !globals.KEYUTIL) return [];
|
||||
const operations: Array<CryptoAdapterOperation | undefined> = [];
|
||||
if (kjur) operations.push(signatureConstructor(kjur));
|
||||
const jws = record(record(record(kjur)?.jws)?.JWS);
|
||||
if (jws) {
|
||||
operations.push(
|
||||
oneShotOperation(jws, 'sign', 'JWS.sign', 'signature', ['algorithm', 'options', 'data', 'key', 'options'], 2, 'sign'),
|
||||
oneShotOperation(jws, 'verify', 'JWS.verify', 'signature', ['data', 'key', 'options'], -1, undefined),
|
||||
oneShotOperation(jws, 'verifyJWT', 'JWT.verify', 'signature', ['data', 'key', 'options'], -1, undefined),
|
||||
oneShotOperation(jws, 'getJWKthumbprint', 'JWK.thumbprint', 'key-management', ['key'], -1, undefined),
|
||||
);
|
||||
}
|
||||
const keyutil = globals.KEYUTIL;
|
||||
if (keyutil) {
|
||||
operations.push(
|
||||
oneShotOperation(keyutil, 'getKey', 'KEYUTIL.getKey', 'key-management', ['key', 'options', 'options'], -1, undefined),
|
||||
oneShotOperation(keyutil, 'getJWK', 'KEYUTIL.getJWK', 'key-management', ['key', 'options', 'options'], -1, undefined),
|
||||
oneShotOperation(keyutil, 'getPEM', 'KEYUTIL.getPEM', 'key-management', ['key', 'options', 'options'], -1, undefined),
|
||||
);
|
||||
}
|
||||
return operations.filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { BrowserRecordingCrypto, BrowserRecordingValueEvidence } from '@/types/models';
|
||||
import type {
|
||||
CallableOperationKind,
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { nodeForgeManifest } from './catalog';
|
||||
|
||||
type ForgeKeyKind = 'public' | 'private';
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && (typeof value === 'object' || typeof value === 'function')
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function target(root: Record<string, unknown>, path: string): { owner: Record<string, unknown>; key: string } | undefined {
|
||||
const segments = path.split('.');
|
||||
let owner = root;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const next = record(owner[segment]);
|
||||
if (!next) return undefined;
|
||||
owner = next;
|
||||
}
|
||||
return { owner, key: segments.at(-1)! };
|
||||
}
|
||||
|
||||
function method(owner: Record<string, unknown>, key: string): boolean {
|
||||
try { return typeof owner[key] === 'function'; } catch { return false; }
|
||||
}
|
||||
|
||||
function wrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
|
||||
return function recordedNodeForge(this: unknown, ...args: unknown[]) { return invoke(this, args); };
|
||||
}
|
||||
|
||||
function forgeBufferValue(value: unknown): unknown {
|
||||
const input = record(value);
|
||||
if (!input) return value;
|
||||
try {
|
||||
if (typeof input.bytes === 'function') {
|
||||
const lengthValue = typeof input.length === 'function' ? Reflect.apply(input.length as Function, value, []) : undefined;
|
||||
const length = Number.isFinite(lengthValue) ? Math.max(0, Math.min(Number(lengthValue), 262_144)) : undefined;
|
||||
return Reflect.apply(input.bytes as Function, value, length === undefined ? [] : [length]);
|
||||
}
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function bufferEvidence(value: unknown, path: string, toolkit: CryptoAdapterToolkit): BrowserRecordingValueEvidence[] {
|
||||
return toolkit.collectEvidence(forgeBufferValue(value), path);
|
||||
}
|
||||
|
||||
function keyMetadata(value: unknown, kind: NonNullable<BrowserRecordingCrypto['key']>['kind'], toolkit: CryptoAdapterToolkit): BrowserRecordingCrypto['key'] {
|
||||
const input = record(value);
|
||||
let bits: number | undefined;
|
||||
let publicMaterial: string | undefined;
|
||||
try {
|
||||
const modulus = record(input?.n);
|
||||
if (typeof modulus?.bitLength === 'function') {
|
||||
const result = Reflect.apply(modulus.bitLength as Function, input?.n, []);
|
||||
if (Number.isSafeInteger(result) && result >= 256 && result <= 32_768) bits = Number(result);
|
||||
}
|
||||
if (typeof modulus?.toString === 'function') {
|
||||
publicMaterial = `${Reflect.apply(modulus.toString as Function, input?.n, [16])}:${String(input?.e || '')}`;
|
||||
}
|
||||
} catch {
|
||||
// Compatible forge builds may hide bigint internals.
|
||||
}
|
||||
if (!publicMaterial) {
|
||||
try {
|
||||
if (typeof value === 'string') publicMaterial = value;
|
||||
else {
|
||||
const bytes = toolkit.bytesForInput(forgeBufferValue(value));
|
||||
if (bytes) publicMaterial = toolkit.bytesToBase64(bytes);
|
||||
}
|
||||
} catch {
|
||||
publicMaterial = undefined;
|
||||
}
|
||||
}
|
||||
return { kind, bits, fingerprint: publicMaterial ? toolkit.fingerprint(publicMaterial) : undefined };
|
||||
}
|
||||
|
||||
function crypto(
|
||||
family: BrowserRecordingCrypto['family'],
|
||||
operation: string,
|
||||
algorithm: string | undefined,
|
||||
model: NonNullable<BrowserRecordingCrypto['state']>['model'],
|
||||
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
|
||||
correlationId: string,
|
||||
key?: BrowserRecordingCrypto['key'],
|
||||
): BrowserRecordingCrypto {
|
||||
return {
|
||||
adapterId: nodeForgeManifest.id,
|
||||
providerKind: nodeForgeManifest.providerKind,
|
||||
family,
|
||||
operation,
|
||||
algorithm,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: 'auto',
|
||||
state: { model, phase, correlationId },
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
function outputBufferOperations(
|
||||
buffer: unknown,
|
||||
correlationId: string,
|
||||
family: BrowserRecordingCrypto['family'],
|
||||
algorithm: string | undefined,
|
||||
operationPrefix: string,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(buffer);
|
||||
if (!owner) return [];
|
||||
return ['getBytes', 'bytes', 'toHex'].filter((key) => method(owner, key)).map((key) => ({
|
||||
id: `node-forge.${correlationId}.${operationPrefix}.${key}`,
|
||||
operation: `${operationPrefix}.output.${key}`,
|
||||
owner,
|
||||
key,
|
||||
resultMode: 'sync' as const,
|
||||
describe: (_thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
|
||||
crypto: crypto(family, `${operationPrefix}.output.${key}`, algorithm, 'stream', 'final', correlationId),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 2).map((value, index) => toolkit.argument(index, 'options', value, false, false)),
|
||||
outputEvidence: (value) => toolkit.collectEvidence(value, '$output'),
|
||||
}),
|
||||
createWrapper: (_original: Function, invoke: (thisArg: unknown, args: unknown[]) => unknown) => wrapper(invoke),
|
||||
}));
|
||||
}
|
||||
|
||||
function rsaOperations(
|
||||
value: unknown,
|
||||
kind: ForgeKeyKind,
|
||||
correlationId: string,
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const metadata = keyMetadata(value, kind, toolkit);
|
||||
const definitions: Array<{
|
||||
key: string;
|
||||
operation: string;
|
||||
family: BrowserRecordingCrypto['family'];
|
||||
callable?: CallableOperationKind;
|
||||
roles: Array<'data' | 'signature' | 'options'>;
|
||||
}> = kind === 'public' ? [
|
||||
{ key: 'encrypt', operation: 'rsa.encrypt', family: 'asymmetric', callable: 'encrypt', roles: ['data', 'options', 'options'] },
|
||||
{ key: 'verify', operation: 'rsa.verify', family: 'signature', roles: ['data', 'signature', 'options'] },
|
||||
] : [
|
||||
{ key: 'decrypt', operation: 'rsa.decrypt', family: 'asymmetric', callable: 'decrypt', roles: ['data', 'options', 'options'] },
|
||||
{ key: 'sign', operation: 'rsa.sign', family: 'signature', roles: ['data', 'options'] },
|
||||
];
|
||||
return definitions.filter((definition) => method(owner, definition.key)).map((definition) => ({
|
||||
id: `node-forge.${correlationId}.${definition.operation}`,
|
||||
operation: definition.operation,
|
||||
owner,
|
||||
key: definition.key,
|
||||
resultMode: 'sync' as const,
|
||||
describe: (_thisArg: unknown, args: unknown[], adapterToolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
|
||||
crypto: crypto(definition.family, definition.operation, 'RSA', 'receiver', 'one-shot', correlationId, metadata),
|
||||
inputIndex: 0,
|
||||
callableKind: definition.callable,
|
||||
arguments: args.slice(0, 8).map((argument, index) => adapterToolkit.argument(
|
||||
index, definition.roles[index] || 'unknown', argument, index === 0, Boolean(definition.callable),
|
||||
)),
|
||||
outputError: (result) => result === false || result === null ? 'node-forge RSA returned no result' : undefined,
|
||||
adaptInput: (input) => adapterToolkit.defaultAdaptInput(input, args[0]),
|
||||
}),
|
||||
createWrapper: (_original: Function, invoke: (thisArg: unknown, args: unknown[]) => unknown) => wrapper(invoke),
|
||||
}));
|
||||
}
|
||||
|
||||
function cipherSessionOperations(
|
||||
value: unknown,
|
||||
direction: 'encrypt' | 'decrypt',
|
||||
algorithm: string | undefined,
|
||||
correlationId: string,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const family: BrowserRecordingCrypto['family'] = 'symmetric';
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
const definitions = [
|
||||
{ key: 'start', phase: 'init' as const, inputIndex: -1, role: 'options' as const },
|
||||
{ key: 'update', phase: 'update' as const, inputIndex: 0, role: 'data' as const },
|
||||
{ key: 'finish', phase: 'final' as const, inputIndex: -1, role: 'options' as const },
|
||||
];
|
||||
for (const definition of definitions) {
|
||||
if (!method(owner, definition.key)) continue;
|
||||
const operation = `cipher.${direction}.${definition.key}`;
|
||||
output.push({
|
||||
id: `node-forge.${correlationId}.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key: definition.key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => ({
|
||||
crypto: crypto(family, operation, algorithm, 'stream', definition.phase, correlationId),
|
||||
inputIndex: definition.inputIndex,
|
||||
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
|
||||
index, index === 0 ? definition.role : 'options', argument, false, false,
|
||||
)),
|
||||
outputEvidence: definition.key === 'finish'
|
||||
? () => bufferEvidence(owner.output, '$receiver.output', toolkit)
|
||||
: () => [],
|
||||
inputEvidence: definition.key === 'update'
|
||||
? (input) => bufferEvidence(input, '$input', toolkit)
|
||||
: undefined,
|
||||
discoverResult: definition.key === 'start'
|
||||
? () => outputBufferOperations(owner.output, correlationId, family, algorithm, `cipher.${direction}`)
|
||||
: undefined,
|
||||
outputError: definition.key === 'finish'
|
||||
? (result) => result === false ? 'node-forge cipher authentication or padding failed' : undefined
|
||||
: undefined,
|
||||
}),
|
||||
createWrapper: (_original, invoke) => wrapper(invoke),
|
||||
});
|
||||
}
|
||||
output.push(...outputBufferOperations(owner.output, correlationId, family, algorithm, `cipher.${direction}`));
|
||||
return output;
|
||||
}
|
||||
|
||||
function digestSessionOperations(
|
||||
value: unknown,
|
||||
algorithm: string,
|
||||
correlationId: string,
|
||||
): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
for (const definition of [
|
||||
{ key: 'start', phase: 'init' as const, inputIndex: -1 },
|
||||
{ key: 'update', phase: 'update' as const, inputIndex: 0 },
|
||||
{ key: 'digest', phase: 'final' as const, inputIndex: -1 },
|
||||
]) {
|
||||
if (!method(owner, definition.key)) continue;
|
||||
const operation = `digest.${definition.key}`;
|
||||
output.push({
|
||||
id: `node-forge.${correlationId}.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key: definition.key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => ({
|
||||
crypto: crypto('digest', operation, algorithm, 'session', definition.phase, correlationId),
|
||||
inputIndex: definition.inputIndex,
|
||||
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
|
||||
outputEvidence: definition.key === 'digest'
|
||||
? (result) => bufferEvidence(result, '$output', toolkit)
|
||||
: () => [],
|
||||
inputEvidence: definition.key === 'update'
|
||||
? (input) => bufferEvidence(input, '$input', toolkit)
|
||||
: undefined,
|
||||
discoverResult: definition.key === 'digest'
|
||||
? (result) => outputBufferOperations(result, correlationId, 'digest', algorithm, 'digest')
|
||||
: undefined,
|
||||
}),
|
||||
createWrapper: (_original, invoke) => wrapper(invoke),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function hmacSessionOperations(value: unknown, correlationId: string): CryptoAdapterOperation[] {
|
||||
const owner = record(value);
|
||||
if (!owner) return [];
|
||||
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = {};
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
for (const definition of [
|
||||
{ key: 'start', phase: 'init' as const, inputIndex: -1 },
|
||||
{ key: 'update', phase: 'update' as const, inputIndex: 0 },
|
||||
{ key: 'digest', phase: 'final' as const, inputIndex: -1 },
|
||||
]) {
|
||||
if (!method(owner, definition.key)) continue;
|
||||
const operation = `hmac.${definition.key}`;
|
||||
output.push({
|
||||
id: `node-forge.${correlationId}.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key: definition.key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
if (definition.key === 'start') {
|
||||
context.algorithm = typeof args[0] === 'string' ? args[0].slice(0, 80) : 'HMAC';
|
||||
context.key = keyMetadata(args[1], 'secret', toolkit);
|
||||
}
|
||||
return {
|
||||
crypto: crypto('mac', operation, context.algorithm || 'HMAC', 'session', definition.phase, correlationId, context.key),
|
||||
inputIndex: definition.inputIndex,
|
||||
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(
|
||||
index, definition.key === 'start' && index === 1 ? 'key' : index === 0 ? 'data' : 'options', argument, false, false,
|
||||
)),
|
||||
outputEvidence: definition.key === 'digest'
|
||||
? (result) => bufferEvidence(result, '$output', toolkit)
|
||||
: () => [],
|
||||
inputEvidence: definition.key === 'update'
|
||||
? (input) => bufferEvidence(input, '$input', toolkit)
|
||||
: undefined,
|
||||
discoverResult: definition.key === 'digest'
|
||||
? (result) => outputBufferOperations(result, correlationId, 'mac', context.algorithm || 'HMAC', 'hmac')
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => wrapper(invoke),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function factoryOperation(
|
||||
root: Record<string, unknown>,
|
||||
path: string,
|
||||
operation: string,
|
||||
family: BrowserRecordingCrypto['family'],
|
||||
algorithm: (args: unknown[]) => string | undefined,
|
||||
discover: (value: unknown, args: unknown[], correlationId: string, toolkit: CryptoAdapterToolkit) => CryptoAdapterOperation[],
|
||||
roles: Array<'algorithm' | 'key' | 'options' | 'unknown'>,
|
||||
): CryptoAdapterOperation | undefined {
|
||||
const resolved = target(root, path);
|
||||
if (!resolved) return undefined;
|
||||
return {
|
||||
id: `node-forge.${operation}`,
|
||||
operation,
|
||||
owner: resolved.owner,
|
||||
key: resolved.key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => {
|
||||
const correlationId = toolkit.unique('forge-session');
|
||||
const algorithmName = algorithm(args);
|
||||
const keyIndex = roles.indexOf('key');
|
||||
const factoryKey = family === 'symmetric' && keyIndex >= 0
|
||||
? keyMetadata(args[keyIndex], 'secret', toolkit)
|
||||
: undefined;
|
||||
return {
|
||||
crypto: crypto(family, operation, algorithmName, 'session', 'create', correlationId, factoryKey),
|
||||
inputIndex: -1,
|
||||
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
|
||||
index, roles[index] || 'unknown', argument, false, false,
|
||||
roles[index] === 'algorithm' && typeof argument === 'string' ? argument.slice(0, 120) : undefined,
|
||||
)),
|
||||
outputEvidence: () => [],
|
||||
discoverResult: (value) => discover(value, args, correlationId, toolkit),
|
||||
};
|
||||
},
|
||||
createWrapper: (_original, invoke) => wrapper(invoke),
|
||||
};
|
||||
}
|
||||
|
||||
export const nodeForgeAdapter: PageCryptoAdapter = {
|
||||
manifest: nodeForgeManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const forge = (scope.window as unknown as { forge?: Record<string, unknown> }).forge;
|
||||
if (!forge) return [];
|
||||
const operations: Array<CryptoAdapterOperation | undefined> = [
|
||||
factoryOperation(forge, 'cipher.createCipher', 'cipher.create.encrypt', 'symmetric',
|
||||
(args) => typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined,
|
||||
(value, args, correlationId) => cipherSessionOperations(value, 'encrypt', typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined, correlationId),
|
||||
['algorithm', 'key']),
|
||||
factoryOperation(forge, 'cipher.createDecipher', 'cipher.create.decrypt', 'symmetric',
|
||||
(args) => typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined,
|
||||
(value, args, correlationId) => cipherSessionOperations(value, 'decrypt', typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined, correlationId),
|
||||
['algorithm', 'key']),
|
||||
factoryOperation(forge, 'hmac.create', 'hmac.create', 'mac', () => 'HMAC',
|
||||
(value, _args, correlationId) => hmacSessionOperations(value, correlationId), []),
|
||||
factoryOperation(forge, 'pki.publicKeyFromPem', 'pki.public-key.create', 'key-management', () => 'RSA',
|
||||
(value, _args, correlationId, toolkit) => rsaOperations(value, 'public', correlationId, toolkit), ['key']),
|
||||
factoryOperation(forge, 'pki.privateKeyFromPem', 'pki.private-key.create', 'key-management', () => 'RSA',
|
||||
(value, _args, correlationId, toolkit) => rsaOperations(value, 'private', correlationId, toolkit), ['key']),
|
||||
];
|
||||
for (const algorithm of ['md5', 'sha1', 'sha256', 'sha384', 'sha512']) {
|
||||
operations.push(factoryOperation(
|
||||
forge,
|
||||
`md.${algorithm}.create`,
|
||||
`digest.${algorithm}.create`,
|
||||
'digest',
|
||||
() => algorithm.toUpperCase(),
|
||||
(value, _args, correlationId) => digestSessionOperations(value, algorithm.toUpperCase(), correlationId),
|
||||
[],
|
||||
));
|
||||
}
|
||||
return operations.filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KEYUTIL, KJUR } from 'jsrsasign';
|
||||
import {
|
||||
CompactEncrypt,
|
||||
CompactSign,
|
||||
SignJWT,
|
||||
compactDecrypt,
|
||||
compactVerify,
|
||||
jwtVerify,
|
||||
} from 'jose';
|
||||
import type { CryptoAdapterToolkit } from './contract';
|
||||
import { jsrsasignAdapter } from './jsrsasign';
|
||||
import { joseAdapter } from './jose';
|
||||
|
||||
function toolkit(): CryptoAdapterToolkit {
|
||||
return {
|
||||
unique: (prefix) => `${prefix}-acceptance`,
|
||||
byteLength: (value) => typeof value === 'string' ? new TextEncoder().encode(value).byteLength
|
||||
: ArrayBuffer.isView(value) ? value.byteLength
|
||||
: value instanceof ArrayBuffer ? value.byteLength : undefined,
|
||||
dataType: (value) => Object.prototype.toString.call(value).slice(8, -1),
|
||||
fingerprint: (value) => `opaque:${value.length}`,
|
||||
argument: (index, role, value, replaceable, retained, summary) => ({
|
||||
index, role, dataType: typeof value, replaceable, retained, summary,
|
||||
}),
|
||||
collectEvidence: (value, path) => [{
|
||||
path, fingerprint: `evidence:${String(value).length}`, encoding: 'text', byteLength: String(value).length,
|
||||
}],
|
||||
defaultOutputEvidence: () => [],
|
||||
defaultAdaptInput: (value) => value,
|
||||
bytesForInput: (value) => value instanceof Uint8Array ? value : undefined,
|
||||
bytesToBase64: (value) => Buffer.from(value).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('modern protocol adapter acceptance', () => {
|
||||
it('tracks a real jsrsasign Signature session and independently verifies its output', { timeout: 15_000 }, () => {
|
||||
const operations = jsrsasignAdapter.discover({
|
||||
window: { KJUR, KEYUTIL } as unknown as Window,
|
||||
});
|
||||
const constructor = operations.find((item) => item.operation === 'Signature.create');
|
||||
const createPlan = constructor?.describe(undefined, [{ alg: 'SHA256withRSA' }], toolkit());
|
||||
const keypair = KEYUTIL.generateKeypair('RSA', 1024);
|
||||
const signer = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
|
||||
const stages = createPlan?.discoverResult?.(signer) || [];
|
||||
const init = stages.find((item) => item.operation === 'Signature.init');
|
||||
const update = stages.find((item) => item.operation === 'Signature.updateString');
|
||||
const final = stages.find((item) => item.operation === 'Signature.sign');
|
||||
const canonical = 'POST\n/api/order\naccount=admin&nonce=1700000000';
|
||||
|
||||
const plans = [
|
||||
init?.describe(signer, [keypair.prvKeyObj], toolkit()),
|
||||
update?.describe(signer, [canonical], toolkit()),
|
||||
final?.describe(signer, [], toolkit()),
|
||||
];
|
||||
signer.init(keypair.prvKeyObj);
|
||||
signer.updateString(canonical);
|
||||
const signature = signer.sign();
|
||||
|
||||
const verifier = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
|
||||
verifier.init(keypair.pubKeyObj);
|
||||
verifier.updateString(canonical);
|
||||
expect(verifier.verify(signature)).toBe(true);
|
||||
expect(plans.map((plan) => plan?.crypto.state?.phase)).toEqual(['init', 'update', 'final']);
|
||||
expect(new Set(plans.map((plan) => plan?.crypto.state?.correlationId))).toEqual(new Set(['jsrsasign-signature-acceptance']));
|
||||
|
||||
const jwkOperation = operations.find((item) => item.operation === 'KEYUTIL.getJWK');
|
||||
const jwkPlan = jwkOperation?.describe(KEYUTIL as unknown as Record<string, unknown>, [keypair.prvKeyObj], toolkit());
|
||||
const privateJwk = KEYUTIL.getJWK(keypair.prvKeyObj);
|
||||
expect(privateJwk).toHaveProperty('d');
|
||||
expect(jwkPlan?.outputEvidence?.(privateJwk)).toEqual([]);
|
||||
});
|
||||
|
||||
it('tracks real jose Promise builders and verifies/decrypts their compact envelopes independently', async () => {
|
||||
const root = {
|
||||
SignJWT, CompactSign, CompactEncrypt, jwtVerify, compactVerify, compactDecrypt,
|
||||
jwtDecrypt: async () => undefined,
|
||||
importJWK: async () => undefined,
|
||||
exportJWK: async () => undefined,
|
||||
};
|
||||
const operations = joseAdapter.discover({ window: { jose: root } as unknown as Window });
|
||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const signJwtConstructor = operations.find((item) => item.operation === 'SignJWT.create');
|
||||
const createPlan = signJwtConstructor?.describe(undefined, [{ account: 'admin' }], toolkit());
|
||||
const builder = new SignJWT({ account: 'admin' });
|
||||
const stages = createPlan?.discoverResult?.(builder) || [];
|
||||
const headerPlan = stages.find((item) => item.operation === 'SignJWT.setProtectedHeader')
|
||||
?.describe(builder, [{ alg: 'HS256' }], toolkit());
|
||||
builder.setProtectedHeader({ alg: 'HS256' });
|
||||
const signOperation = stages.find((item) => item.operation === 'SignJWT.sign');
|
||||
const signPlan = signOperation?.describe(builder, [secret], toolkit());
|
||||
const tokenPromise = builder.sign(secret);
|
||||
const token = await tokenPromise;
|
||||
const verified = await jwtVerify(token, secret, { algorithms: ['HS256'] });
|
||||
|
||||
expect(verified.payload.account).toBe('admin');
|
||||
expect(signOperation?.resultMode).toBe('promise');
|
||||
expect(headerPlan?.crypto).toMatchObject({ algorithm: 'HS256', state: { phase: 'update' } });
|
||||
expect(signPlan?.crypto).toMatchObject({ algorithm: 'HS256', state: { phase: 'final' } });
|
||||
expect(signPlan?.crypto.state?.correlationId).toBe(createPlan?.crypto.state?.correlationId);
|
||||
|
||||
const payload = new TextEncoder().encode('canonical-request');
|
||||
const jws = await new CompactSign(payload).setProtectedHeader({ alg: 'HS256' }).sign(secret);
|
||||
const checked = await compactVerify(jws, secret, { algorithms: ['HS256'] });
|
||||
expect(new TextDecoder().decode(checked.payload)).toBe('canonical-request');
|
||||
|
||||
const jwe = await new CompactEncrypt(payload).setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }).encrypt(secret);
|
||||
const decrypted = await compactDecrypt(jwe, secret, { keyManagementAlgorithms: ['dir'], contentEncryptionAlgorithms: ['A256GCM'] });
|
||||
expect(new TextDecoder().decode(decrypted.plaintext)).toBe('canonical-request');
|
||||
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
|
||||
expect(operations.find((item) => item.operation === 'CompactDecrypt.decrypt')?.resultMode).toBe('promise');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterScope,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { createCryptoAdapterRuntime } from './registry';
|
||||
|
||||
interface FakeDocument {
|
||||
addEventListener(type: string, listener: EventListener, capture?: boolean): void;
|
||||
removeEventListener(type: string, listener: EventListener, capture?: boolean): void;
|
||||
emitScriptLoad(): void;
|
||||
}
|
||||
|
||||
function fakeDocument(): FakeDocument {
|
||||
const listeners = new Set<EventListener>();
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
if (type === 'load') listeners.add(listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (type === 'load') listeners.delete(listener);
|
||||
},
|
||||
emitScriptLoad() {
|
||||
for (const listener of listeners) {
|
||||
listener({ target: { tagName: 'SCRIPT' } } as unknown as Event);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolkit(): CryptoAdapterToolkit {
|
||||
return {
|
||||
unique: (prefix) => `${prefix}-1`,
|
||||
byteLength: () => undefined,
|
||||
dataType: () => 'unknown',
|
||||
fingerprint: (value) => value,
|
||||
argument: (index, role, _value, replaceable, retained, summary) => ({
|
||||
index, role, dataType: 'unknown', replaceable, retained, summary,
|
||||
}),
|
||||
collectEvidence: () => [],
|
||||
defaultOutputEvidence: () => [],
|
||||
defaultAdaptInput: (value) => value,
|
||||
bytesForInput: () => undefined,
|
||||
bytesToBase64: () => '',
|
||||
};
|
||||
}
|
||||
|
||||
function operation(owner: Record<string, unknown>): CryptoAdapterOperation {
|
||||
return {
|
||||
id: 'vendor.encrypt',
|
||||
operation: 'encrypt',
|
||||
owner,
|
||||
key: 'encrypt',
|
||||
resultMode: 'sync',
|
||||
describe: () => ({
|
||||
crypto: {
|
||||
adapterId: 'vendor', providerKind: 'library', family: 'symmetric', operation: 'encrypt',
|
||||
},
|
||||
inputIndex: 0,
|
||||
arguments: [],
|
||||
}),
|
||||
createWrapper: (_original, invoke) => function recordedVendor(this: unknown, ...args: unknown[]) {
|
||||
return invoke(this, args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adapter(owner: Record<string, unknown>, dynamic = true, onDiscover?: () => void): PageCryptoAdapter {
|
||||
return {
|
||||
manifest: {
|
||||
id: 'vendor', displayName: 'Vendor', providerKind: 'library', dynamic, globalPaths: ['Vendor'],
|
||||
},
|
||||
discover: () => {
|
||||
onDiscover?.();
|
||||
return [operation(owner)];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scope(document: FakeDocument): CryptoAdapterScope {
|
||||
return {
|
||||
window: {
|
||||
document,
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
} as unknown as Window,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('crypto adapter runtime', () => {
|
||||
it('preserves call semantics and restores the original property descriptor', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const owner: Record<string, unknown> = {};
|
||||
const original = function originalEncrypt(this: { prefix: string }, value: string) {
|
||||
return `${this.prefix}:${value}`;
|
||||
};
|
||||
Object.defineProperty(owner, 'encrypt', {
|
||||
value: original, configurable: true, writable: false, enumerable: false,
|
||||
});
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(owner, 'encrypt');
|
||||
let handle = '';
|
||||
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
|
||||
unique: () => 'wrapper-stable',
|
||||
invoke(_operation, target, thisArg, args, wrapperHandleId) {
|
||||
handle = wrapperHandleId;
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
const wrapped = owner.encrypt as Function;
|
||||
expect(wrapped).not.toBe(original);
|
||||
expect(Reflect.apply(wrapped, { prefix: 'ok' }, ['value'])).toBe('ok:value');
|
||||
expect(handle).toBe('wrapper-stable');
|
||||
expect(runtime.wrapperFunction(handle)).toBe(wrapped);
|
||||
|
||||
runtime.stop();
|
||||
expect(Object.getOwnPropertyDescriptor(owner, 'encrypt')).toEqual(originalDescriptor);
|
||||
expect(runtime.wrapperFunction(handle)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a stable wrapper handle across stop and restart in one document', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const owner = { encrypt: (value: string) => value } as Record<string, unknown>;
|
||||
let generated = 0;
|
||||
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
|
||||
unique: () => `wrapper-${++generated}`,
|
||||
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
expect(runtime.wrapperFunction('wrapper-1')).toBe(owner.encrypt);
|
||||
runtime.stop();
|
||||
runtime.start();
|
||||
|
||||
expect(generated).toBe(1);
|
||||
expect(runtime.wrapperFunction('wrapper-1')).toBe(owner.encrypt);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('does not overwrite a page replacement during cleanup', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const original = (value: string) => value;
|
||||
const replacement = (value: string) => `page:${value}`;
|
||||
const owner = { encrypt: original } as Record<string, unknown>;
|
||||
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
|
||||
unique: () => 'wrapper-1',
|
||||
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
owner.encrypt = replacement;
|
||||
runtime.stop();
|
||||
|
||||
expect(owner.encrypt).toBe(replacement);
|
||||
});
|
||||
|
||||
it('uses only bounded retries and reacts to script loads for dynamic adapters', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const owner = { encrypt: (value: string) => value } as Record<string, unknown>;
|
||||
let discoveries = 0;
|
||||
const runtime = createCryptoAdapterRuntime(
|
||||
[adapter(owner, true, () => { discoveries += 1; })],
|
||||
scope(document),
|
||||
toolkit(),
|
||||
{
|
||||
unique: () => 'wrapper-1',
|
||||
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
|
||||
},
|
||||
);
|
||||
|
||||
runtime.start();
|
||||
expect(discoveries).toBe(1);
|
||||
vi.runAllTimers();
|
||||
expect(discoveries).toBe(5);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
document.emitScriptLoad();
|
||||
expect(discoveries).toBe(6);
|
||||
runtime.stop();
|
||||
document.emitScriptLoad();
|
||||
expect(discoveries).toBe(6);
|
||||
});
|
||||
|
||||
it('installs returned session operations immediately and restores them across restart', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const sessionPrototype = { update: (value: string) => `session:${value}` };
|
||||
const session = Object.create(sessionPrototype) as Record<string, unknown>;
|
||||
const originalUpdate = session.update;
|
||||
const owner = { create: () => session } as Record<string, unknown>;
|
||||
const sessionOperation = operation(session);
|
||||
sessionOperation.id = 'vendor.session-1.update';
|
||||
sessionOperation.operation = 'session.update';
|
||||
sessionOperation.key = 'update';
|
||||
const factoryOperation: CryptoAdapterOperation = {
|
||||
...operation(owner),
|
||||
id: 'vendor.create',
|
||||
operation: 'session.create',
|
||||
key: 'create',
|
||||
describe: () => ({
|
||||
crypto: { adapterId: 'vendor', providerKind: 'library', family: 'symmetric', operation: 'session.create' },
|
||||
inputIndex: -1,
|
||||
arguments: [],
|
||||
discoverResult: () => [sessionOperation],
|
||||
}),
|
||||
};
|
||||
const factoryAdapter: PageCryptoAdapter = {
|
||||
manifest: { id: 'vendor', displayName: 'Vendor', providerKind: 'library', dynamic: true, globalPaths: ['Vendor'] },
|
||||
discover: () => [factoryOperation],
|
||||
};
|
||||
let generated = 0;
|
||||
const runtime = createCryptoAdapterRuntime([factoryAdapter], scope(document), toolkit(), {
|
||||
unique: () => `wrapper-${++generated}`,
|
||||
invoke(adapterOperation, target, thisArg, args, _wrapperHandleId, installDynamic) {
|
||||
const output = Reflect.apply(target, thisArg, args);
|
||||
installDynamic(adapterOperation.describe(thisArg, args, toolkit()).discoverResult?.(output) || []);
|
||||
return output;
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
const returned = Reflect.apply(owner.create as Function, owner, []) as Record<string, unknown>;
|
||||
const firstWrapped = returned.update as Function;
|
||||
expect(firstWrapped).not.toBe(originalUpdate);
|
||||
expect(Reflect.apply(firstWrapped, returned, ['value'])).toBe('session:value');
|
||||
expect(runtime.wrapperFunction('wrapper-2')).toBe(firstWrapped);
|
||||
|
||||
runtime.stop();
|
||||
expect(session.update).toBe(originalUpdate);
|
||||
expect(Object.prototype.hasOwnProperty.call(session, 'update')).toBe(false);
|
||||
runtime.start();
|
||||
expect(session.update).not.toBe(originalUpdate);
|
||||
expect(Object.prototype.hasOwnProperty.call(session, 'update')).toBe(true);
|
||||
expect(runtime.wrapperFunction('wrapper-2')).toBe(session.update);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('skips non-configurable accessors without invoking their getter', () => {
|
||||
vi.useFakeTimers();
|
||||
const document = fakeDocument();
|
||||
const getter = vi.fn(() => () => 'secret');
|
||||
const owner: Record<string, unknown> = {};
|
||||
Object.defineProperty(owner, 'encrypt', { get: getter, configurable: false });
|
||||
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
|
||||
unique: () => 'wrapper-1',
|
||||
invoke: () => undefined,
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
expect(Object.getOwnPropertyDescriptor(owner, 'encrypt')?.get).toBe(getter);
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterScope,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
|
||||
export interface CryptoAdapterRuntimeHost {
|
||||
unique(prefix: string): string;
|
||||
invoke(
|
||||
operation: CryptoAdapterOperation,
|
||||
original: Function,
|
||||
thisArg: unknown,
|
||||
args: unknown[],
|
||||
wrapperHandleId: string,
|
||||
installDynamic: (operations: CryptoAdapterOperation[]) => void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export interface CryptoAdapterRuntime {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
ensureDynamic(): void;
|
||||
wrapperFunction(wrapperHandleId: string): Function | undefined;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS = [50, 250, 1_000, 3_000] as const;
|
||||
|
||||
export function createCryptoAdapterRuntime(
|
||||
adapters: PageCryptoAdapter[],
|
||||
scope: CryptoAdapterScope,
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
host: CryptoAdapterRuntimeHost,
|
||||
): CryptoAdapterRuntime {
|
||||
const wrappers = new WeakSet<Function>();
|
||||
const handleByTarget = new Map<string, string>();
|
||||
const wrapperByHandle = new Map<string, Function>();
|
||||
const restorers: Array<() => void> = [];
|
||||
const dynamicOperations: Array<{ adapter: PageCryptoAdapter; operation: CryptoAdapterOperation }> = [];
|
||||
const retryTimers = new Set<number>();
|
||||
let active = false;
|
||||
|
||||
const installOperation = (adapter: PageCryptoAdapter, operation: CryptoAdapterOperation): void => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(operation.owner, operation.key);
|
||||
if (descriptor && (!('value' in descriptor) || (!descriptor.writable && !descriptor.configurable))) return;
|
||||
const current = descriptor && 'value' in descriptor ? descriptor.value : operation.owner[operation.key];
|
||||
if (typeof current !== 'function' || wrappers.has(current)) return;
|
||||
const targetKey = `${adapter.manifest.id}:${operation.id}`;
|
||||
const wrapperHandleId = handleByTarget.get(targetKey) || host.unique('wrapper');
|
||||
handleByTarget.set(targetKey, wrapperHandleId);
|
||||
const wrapped = operation.createWrapper(
|
||||
current,
|
||||
(thisArg, args) => host.invoke(operation, current, thisArg, args, wrapperHandleId, (operations) => {
|
||||
for (const discovered of operations.slice(0, 32)) {
|
||||
if (!dynamicOperations.some((item) => item.operation === discovered)) {
|
||||
dynamicOperations.push({ adapter, operation: discovered });
|
||||
if (dynamicOperations.length > 128) dynamicOperations.shift();
|
||||
}
|
||||
try { installOperation(adapter, discovered); } catch { /* Session discovery is best effort. */ }
|
||||
}
|
||||
}),
|
||||
);
|
||||
try {
|
||||
if (descriptor) Object.defineProperty(operation.owner, operation.key, { ...descriptor, value: wrapped });
|
||||
else operation.owner[operation.key] = wrapped;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
wrappers.add(wrapped);
|
||||
wrapperByHandle.set(wrapperHandleId, wrapped);
|
||||
restorers.push(() => {
|
||||
if (operation.owner[operation.key] === wrapped) {
|
||||
try {
|
||||
if (descriptor) Object.defineProperty(operation.owner, operation.key, descriptor);
|
||||
else delete operation.owner[operation.key];
|
||||
} catch {
|
||||
// A page replacement wins over recorder cleanup.
|
||||
}
|
||||
}
|
||||
if (wrapperByHandle.get(wrapperHandleId) === wrapped) wrapperByHandle.delete(wrapperHandleId);
|
||||
});
|
||||
};
|
||||
|
||||
const install = (dynamicOnly: boolean): void => {
|
||||
if (!active) return;
|
||||
for (const adapter of adapters) {
|
||||
if (dynamicOnly && !adapter.manifest.dynamic) continue;
|
||||
let operations: CryptoAdapterOperation[] = [];
|
||||
try { operations = adapter.discover(scope); } catch { continue; }
|
||||
for (const operation of operations) {
|
||||
try { installOperation(adapter, operation); } catch { /* One adapter cannot break recording. */ }
|
||||
}
|
||||
}
|
||||
if (!dynamicOnly) {
|
||||
for (const item of dynamicOperations) {
|
||||
try { installOperation(item.adapter, item.operation); } catch { /* A stale session is ignored. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDynamic = (): void => install(true);
|
||||
const onResourceLoad = (event: Event): void => {
|
||||
const target = event.target as { tagName?: unknown } | null;
|
||||
const ScriptElement = (scope.window as unknown as {
|
||||
HTMLScriptElement?: typeof HTMLScriptElement;
|
||||
}).HTMLScriptElement;
|
||||
const isScript = typeof ScriptElement === 'function'
|
||||
? target instanceof ScriptElement
|
||||
: target?.tagName === 'SCRIPT';
|
||||
if (isScript) ensureDynamic();
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
install(false);
|
||||
scope.window.document.addEventListener('load', onResourceLoad, true);
|
||||
for (const delay of RETRY_DELAYS) {
|
||||
const timer = scope.window.setTimeout(() => {
|
||||
retryTimers.delete(timer);
|
||||
ensureDynamic();
|
||||
}, delay);
|
||||
retryTimers.add(timer);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
scope.window.document.removeEventListener('load', onResourceLoad, true);
|
||||
for (const timer of retryTimers) scope.window.clearTimeout(timer);
|
||||
retryTimers.clear();
|
||||
while (restorers.length) {
|
||||
try { restorers.pop()!(); } catch { /* Cleanup is best effort. */ }
|
||||
}
|
||||
wrapperByHandle.clear();
|
||||
},
|
||||
ensureDynamic,
|
||||
wrapperFunction(wrapperHandleId) {
|
||||
return wrapperByHandle.get(wrapperHandleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { BrowserRecordingCrypto } from '@/types/models';
|
||||
import type {
|
||||
CallableOperationKind,
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { smCryptoManifest } from './catalog';
|
||||
|
||||
interface SMOperationDefinition {
|
||||
path: string;
|
||||
operation: string;
|
||||
family: BrowserRecordingCrypto['family'];
|
||||
algorithm: string;
|
||||
callableKind?: CallableOperationKind;
|
||||
roles: Array<'data' | 'key' | 'signature' | 'options' | 'unknown'>;
|
||||
keyKind?: NonNullable<BrowserRecordingCrypto['key']>['kind'];
|
||||
keyBits?: number;
|
||||
outputEncoding?: BrowserRecordingCrypto['outputEncoding'];
|
||||
}
|
||||
|
||||
const OPERATIONS: SMOperationDefinition[] = [
|
||||
{ path: 'sm2.doEncrypt', operation: 'sm2.encrypt', family: 'asymmetric', algorithm: 'SM2', callableKind: 'encrypt', roles: ['data', 'key', 'options'], keyKind: 'public', keyBits: 256, outputEncoding: 'hex' },
|
||||
{ path: 'sm2.doDecrypt', operation: 'sm2.decrypt', family: 'asymmetric', algorithm: 'SM2', callableKind: 'decrypt', roles: ['data', 'key', 'options', 'options'], keyKind: 'private', keyBits: 256, outputEncoding: 'utf8' },
|
||||
{ path: 'sm2.doSignature', operation: 'sm2.sign', family: 'signature', algorithm: 'SM2', callableKind: 'sign', roles: ['data', 'key', 'options'], keyKind: 'private', keyBits: 256, outputEncoding: 'hex' },
|
||||
{ path: 'sm2.doVerifySignature', operation: 'sm2.verify', family: 'signature', algorithm: 'SM2', callableKind: 'verify', roles: ['data', 'signature', 'key', 'options'], keyKind: 'public', keyBits: 256, outputEncoding: 'auto' },
|
||||
{ path: 'sm3', operation: 'sm3.digest', family: 'digest', algorithm: 'SM3', callableKind: 'digest', roles: ['data', 'options'], outputEncoding: 'hex' },
|
||||
{ path: 'sm4.encrypt', operation: 'sm4.encrypt', family: 'symmetric', algorithm: 'SM4', callableKind: 'encrypt', roles: ['data', 'key', 'options'], keyKind: 'secret', keyBits: 128, outputEncoding: 'hex' },
|
||||
{ path: 'sm4.decrypt', operation: 'sm4.decrypt', family: 'symmetric', algorithm: 'SM4', callableKind: 'decrypt', roles: ['data', 'key', 'options'], keyKind: 'secret', keyBits: 128, outputEncoding: 'utf8' },
|
||||
];
|
||||
|
||||
function ownValue(value: unknown, key: string): unknown {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function optionSummary(definition: SMOperationDefinition, args: unknown[], toolkit: CryptoAdapterToolkit): {
|
||||
mode?: string;
|
||||
padding?: string;
|
||||
summary?: string;
|
||||
inputEncoding?: BrowserRecordingCrypto['inputEncoding'];
|
||||
outputEncoding?: BrowserRecordingCrypto['outputEncoding'];
|
||||
} {
|
||||
const optionValues = definition.roles
|
||||
.map((role, index) => role === 'options' ? args[index] : undefined)
|
||||
.filter((value) => value !== undefined);
|
||||
const parts: string[] = [];
|
||||
let mode: string | undefined;
|
||||
let padding: string | undefined;
|
||||
let inputEncoding: BrowserRecordingCrypto['inputEncoding'];
|
||||
let outputEncoding = definition.outputEncoding;
|
||||
for (const options of optionValues) {
|
||||
if (definition.algorithm === 'SM2' && typeof options === 'number') {
|
||||
mode = options === 0 ? 'C1C2C3' : options === 1 ? 'C1C3C2' : `cipherMode=${options}`;
|
||||
parts.push(mode);
|
||||
continue;
|
||||
}
|
||||
if (!options || typeof options !== 'object') continue;
|
||||
const rawMode = ownValue(options, 'mode');
|
||||
const rawPadding = ownValue(options, 'padding');
|
||||
const rawInput = ownValue(options, 'input');
|
||||
const rawOutput = ownValue(options, 'output');
|
||||
const iv = ownValue(options, 'iv');
|
||||
if (typeof rawMode === 'string') { mode = rawMode.slice(0, 40); parts.push(`mode=${mode}`); }
|
||||
if (typeof rawPadding === 'string') { padding = rawPadding.slice(0, 40); parts.push(`padding=${padding}`); }
|
||||
if (iv !== undefined) parts.push(`ivBytes=${toolkit.byteLength(iv) || 0}`);
|
||||
if (rawInput === 'utf8' || rawInput === 'hex' || rawInput === 'base64' || rawInput === 'auto') inputEncoding = rawInput;
|
||||
if (rawOutput === 'utf8' || rawOutput === 'hex' || rawOutput === 'base64' || rawOutput === 'auto') outputEncoding = rawOutput;
|
||||
}
|
||||
return { mode, padding, inputEncoding, outputEncoding, summary: parts.length ? parts.join(' ').slice(0, 240) : undefined };
|
||||
}
|
||||
|
||||
function keyMetadata(
|
||||
definition: SMOperationDefinition,
|
||||
args: unknown[],
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): BrowserRecordingCrypto['key'] | undefined {
|
||||
const keyIndex = definition.roles.indexOf('key');
|
||||
if (keyIndex < 0) return undefined;
|
||||
const value = args[keyIndex];
|
||||
let material: string | undefined;
|
||||
try {
|
||||
if (typeof value === 'string') material = value;
|
||||
else {
|
||||
const bytes = toolkit.bytesForInput(value);
|
||||
if (bytes) material = toolkit.bytesToBase64(bytes);
|
||||
}
|
||||
} catch {
|
||||
material = undefined;
|
||||
}
|
||||
return {
|
||||
kind: definition.keyKind || 'unknown',
|
||||
bits: definition.keyBits,
|
||||
fingerprint: material ? toolkit.fingerprint(material) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function operationOwner(root: Record<string, unknown>, path: string): { owner: Record<string, unknown>; key: string } | undefined {
|
||||
const segments = path.split('.');
|
||||
let owner = root;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const next = owner[segment];
|
||||
if (!next || (typeof next !== 'object' && typeof next !== 'function')) return undefined;
|
||||
owner = next as Record<string, unknown>;
|
||||
}
|
||||
return { owner, key: segments.at(-1)! };
|
||||
}
|
||||
|
||||
function describe(
|
||||
definition: SMOperationDefinition,
|
||||
args: unknown[],
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): CryptoAdapterInvocationPlan {
|
||||
const options = optionSummary(definition, args, toolkit);
|
||||
const sm3Key = definition.operation === 'sm3.digest' ? ownValue(args[1], 'key') : undefined;
|
||||
const actualFamily = sm3Key === undefined ? definition.family : 'mac';
|
||||
const actualOperation = sm3Key === undefined ? definition.operation : 'sm3.hmac';
|
||||
const actualCallableKind = sm3Key === undefined ? definition.callableKind : 'sign';
|
||||
let sm3KeyMaterial: string | undefined;
|
||||
if (typeof sm3Key === 'string') sm3KeyMaterial = sm3Key;
|
||||
else if (sm3Key !== undefined) {
|
||||
try {
|
||||
const bytes = toolkit.bytesForInput(sm3Key);
|
||||
if (bytes) sm3KeyMaterial = toolkit.bytesToBase64(bytes);
|
||||
} catch { /* HMAC key metadata is optional. */ }
|
||||
}
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: smCryptoManifest.id,
|
||||
providerKind: smCryptoManifest.providerKind,
|
||||
family: actualFamily,
|
||||
operation: actualOperation,
|
||||
algorithm: definition.algorithm,
|
||||
mode: options.mode,
|
||||
padding: options.padding,
|
||||
inputEncoding: options.inputEncoding || 'auto',
|
||||
outputEncoding: options.outputEncoding,
|
||||
state: { model: 'stateless', phase: 'one-shot' },
|
||||
key: sm3Key === undefined ? keyMetadata(definition, args, toolkit) : {
|
||||
kind: 'secret',
|
||||
fingerprint: sm3KeyMaterial ? toolkit.fingerprint(sm3KeyMaterial) : undefined,
|
||||
},
|
||||
},
|
||||
inputIndex: 0,
|
||||
callableKind: actualCallableKind,
|
||||
outputEncoding: options.outputEncoding,
|
||||
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
|
||||
index,
|
||||
definition.roles[index] || 'unknown',
|
||||
value,
|
||||
index === 0,
|
||||
Boolean(actualCallableKind),
|
||||
definition.roles[index] === 'options' ? options.summary : undefined,
|
||||
)),
|
||||
outputError: (value) => value === false || value === null ? `${definition.algorithm} returned no result` : undefined,
|
||||
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[0]),
|
||||
};
|
||||
}
|
||||
|
||||
export const smCryptoAdapter: PageCryptoAdapter = {
|
||||
manifest: smCryptoManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const globals = scope.window as unknown as {
|
||||
smCrypto?: Record<string, unknown>;
|
||||
sm2?: Record<string, unknown>;
|
||||
sm3?: Function;
|
||||
sm4?: Record<string, unknown>;
|
||||
};
|
||||
const root = globals.smCrypto || {
|
||||
sm2: globals.sm2,
|
||||
sm3: globals.sm3,
|
||||
sm4: globals.sm4,
|
||||
};
|
||||
if (!root.sm2 && !root.sm3 && !root.sm4) return [];
|
||||
const output: CryptoAdapterOperation[] = [];
|
||||
for (const definition of OPERATIONS) {
|
||||
const resolved = !globals.smCrypto && definition.path === 'sm3'
|
||||
? { owner: scope.window as unknown as Record<string, unknown>, key: 'sm3' }
|
||||
: operationOwner(root, definition.path);
|
||||
if (!resolved) continue;
|
||||
output.push({
|
||||
id: `sm-crypto.${definition.operation}`,
|
||||
operation: definition.operation,
|
||||
owner: resolved.owner,
|
||||
key: resolved.key,
|
||||
resultMode: 'sync',
|
||||
describe: (_thisArg, args, toolkit) => describe(definition, args, toolkit),
|
||||
createWrapper: (_original, invoke) => function recordedSmCrypto(this: unknown, ...args: unknown[]) {
|
||||
return invoke(this, args);
|
||||
},
|
||||
});
|
||||
}
|
||||
return output;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { BrowserRecordingCallArgument } from '@/types/models';
|
||||
import { algorithmSummary, callableOperationKind, cryptoFamily } from './common';
|
||||
import type {
|
||||
CryptoAdapterInvocationPlan,
|
||||
CryptoAdapterOperation,
|
||||
CryptoAdapterToolkit,
|
||||
PageCryptoAdapter,
|
||||
} from './contract';
|
||||
import { webCryptoManifest } from './catalog';
|
||||
|
||||
type WebCryptoOperation = 'encrypt' | 'decrypt' | 'sign' | 'verify' | 'digest' | 'deriveBits' | 'deriveKey'
|
||||
| 'generateKey' | 'importKey' | 'exportKey' | 'wrapKey' | 'unwrapKey';
|
||||
|
||||
const OPERATIONS: WebCryptoOperation[] = [
|
||||
'encrypt', 'decrypt', 'sign', 'verify', 'digest', 'deriveBits', 'deriveKey',
|
||||
'generateKey', 'importKey', 'exportKey', 'wrapKey', 'unwrapKey',
|
||||
];
|
||||
|
||||
const ROLES: Partial<Record<WebCryptoOperation, BrowserRecordingCallArgument['role'][]>> = {
|
||||
encrypt: ['algorithm', 'key', 'data'],
|
||||
decrypt: ['algorithm', 'key', 'data'],
|
||||
sign: ['algorithm', 'key', 'data'],
|
||||
verify: ['algorithm', 'key', 'signature', 'data'],
|
||||
digest: ['algorithm', 'data'],
|
||||
deriveBits: ['algorithm', 'key', 'unknown'],
|
||||
deriveKey: ['algorithm', 'key', 'algorithm', 'unknown', 'unknown'],
|
||||
generateKey: ['algorithm', 'unknown', 'unknown'],
|
||||
importKey: ['unknown', 'data', 'algorithm', 'unknown', 'unknown'],
|
||||
exportKey: ['unknown', 'key'],
|
||||
wrapKey: ['unknown', 'key', 'key', 'algorithm'],
|
||||
unwrapKey: ['data', 'key', 'algorithm', 'algorithm', 'unknown', 'unknown'],
|
||||
};
|
||||
|
||||
function inputIndex(operation: WebCryptoOperation): number {
|
||||
if (operation === 'digest') return 1;
|
||||
if (['encrypt', 'decrypt', 'sign'].includes(operation)) return 2;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function describe(
|
||||
operation: WebCryptoOperation,
|
||||
args: unknown[],
|
||||
toolkit: CryptoAdapterToolkit,
|
||||
): CryptoAdapterInvocationPlan {
|
||||
const input = inputIndex(operation);
|
||||
const algorithm = algorithmSummary(args[0], toolkit.byteLength);
|
||||
const callableKind = callableOperationKind(operation);
|
||||
return {
|
||||
crypto: {
|
||||
adapterId: webCryptoManifest.id,
|
||||
providerKind: webCryptoManifest.providerKind,
|
||||
family: cryptoFamily(operation, algorithm),
|
||||
operation,
|
||||
algorithm,
|
||||
inputEncoding: 'auto',
|
||||
outputEncoding: 'auto',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
},
|
||||
inputIndex: input,
|
||||
callableKind: input >= 0 ? callableKind : undefined,
|
||||
outputEncoding: 'auto',
|
||||
arguments: args.slice(0, 8).map((value, index) => {
|
||||
const role = ROLES[operation]?.[index] || 'unknown';
|
||||
return toolkit.argument(
|
||||
index,
|
||||
role,
|
||||
value,
|
||||
index === input,
|
||||
input >= 0 && Boolean(callableKind),
|
||||
role === 'algorithm' ? algorithmSummary(value, toolkit.byteLength) : undefined,
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
operation: WebCryptoOperation,
|
||||
invoke: (thisArg: unknown, args: unknown[]) => unknown,
|
||||
): Function {
|
||||
switch (operation) {
|
||||
case 'encrypt': return function recordedEncrypt(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'decrypt': return function recordedDecrypt(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'sign': return function recordedSign(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'verify': return function recordedVerify(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'digest': return function recordedDigest(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'deriveBits': return function recordedDeriveBits(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'deriveKey': return function recordedDeriveKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'generateKey': return function recordedGenerateKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'importKey': return function recordedImportKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'exportKey': return function recordedExportKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'wrapKey': return function recordedWrapKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
case 'unwrapKey': return function recordedUnwrapKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
|
||||
}
|
||||
}
|
||||
|
||||
export const webCryptoAdapter: PageCryptoAdapter = {
|
||||
manifest: webCryptoManifest,
|
||||
discover(scope): CryptoAdapterOperation[] {
|
||||
const subtle = scope.crypto?.subtle || scope.window.crypto?.subtle;
|
||||
if (!subtle) return [];
|
||||
const owner = Object.getPrototypeOf(subtle) as Record<string, unknown>;
|
||||
return OPERATIONS.map((operation) => ({
|
||||
id: `webcrypto.subtle.${operation}`,
|
||||
operation,
|
||||
owner,
|
||||
key: operation,
|
||||
resultMode: 'promise',
|
||||
describe: (_thisArg, args, toolkit) => describe(operation, args, toolkit),
|
||||
createWrapper: (_original, invoke) => wrapper(operation, invoke),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserRecordingEvent } from '@/types/models';
|
||||
import {
|
||||
cryptoDeepCaptureMatcher,
|
||||
cryptoEventLabel,
|
||||
isForwardCryptoEvent,
|
||||
normalizeBrowserRecordingCrypto,
|
||||
} from './model';
|
||||
|
||||
function cryptoEvent(operation: string): BrowserRecordingEvent {
|
||||
return {
|
||||
id: 'crypto-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
|
||||
kind: 'crypto', operation, inputs: [], outputs: [], sensitiveCaptured: false,
|
||||
wrapperHandleId: 'wrapper-jsencrypt-encrypt',
|
||||
scriptUrl: 'https://example.test/app.js',
|
||||
crypto: {
|
||||
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation,
|
||||
algorithm: 'RSA', padding: 'PKCS1-v1_5', outputEncoding: 'base64',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('browser crypto model', () => {
|
||||
it('normalizes bounded JSEncrypt metadata without carrying key material', () => {
|
||||
expect(normalizeBrowserRecordingCrypto({
|
||||
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
|
||||
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
|
||||
state: { model: 'receiver', phase: 'one-shot', internalState: 'must-not-survive' },
|
||||
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key', pem: 'must-not-survive' },
|
||||
publicKey: 'must-not-survive',
|
||||
})).toEqual({
|
||||
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
|
||||
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
|
||||
state: { model: 'receiver', phase: 'one-shot' },
|
||||
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts bounded adapter IDs while rejecting malformed adapter metadata', () => {
|
||||
expect(normalizeBrowserRecordingCrypto({
|
||||
adapterId: 'vendor-suite.v2', providerKind: 'library', family: 'asymmetric', operation: 'encrypt',
|
||||
})?.adapterId).toBe('vendor-suite.v2');
|
||||
expect(normalizeBrowserRecordingCrypto({
|
||||
adapterId: '<img onerror=1>', providerKind: 'library', family: 'asymmetric', operation: 'encrypt',
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('classifies forward and reverse RSA calls', () => {
|
||||
expect(isForwardCryptoEvent(cryptoEvent('encrypt'))).toBe(true);
|
||||
expect(isForwardCryptoEvent(cryptoEvent('decrypt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses adapter-aware labels and exact wrapper handles for deep capture', () => {
|
||||
const event = cryptoEvent('encrypt');
|
||||
expect(cryptoEventLabel(event)).toBe('JSEncrypt RSA');
|
||||
expect(cryptoDeepCaptureMatcher(event)).toEqual({
|
||||
kind: 'crypto',
|
||||
adapterId: 'jsencrypt',
|
||||
operation: 'encrypt',
|
||||
wrapperHandleId: 'wrapper-jsencrypt-encrypt',
|
||||
scriptUrl: 'https://example.test/app.js',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
BrowserCryptoFamily,
|
||||
BrowserCryptoProviderKind,
|
||||
BrowserPageCallableValueEncoding,
|
||||
BrowserRecordingCrypto,
|
||||
BrowserDeepCaptureMatcher,
|
||||
BrowserRecordingEvent,
|
||||
} from '@/types/models';
|
||||
import { cryptoAdapterLabel } from './adapters/catalog';
|
||||
|
||||
const PROVIDER_KINDS: BrowserCryptoProviderKind[] = ['native', 'library', 'business', 'wasm', 'unknown'];
|
||||
const FAMILIES: BrowserCryptoFamily[] = [
|
||||
'symmetric', 'asymmetric', 'digest', 'mac', 'signature', 'kdf', 'key-management', 'unknown',
|
||||
];
|
||||
const ENCODINGS: BrowserPageCallableValueEncoding[] = ['auto', 'utf8', 'hex', 'base64', 'json'];
|
||||
const ADAPTER_ID = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
||||
const OPERATION_ID = /^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/;
|
||||
|
||||
function optionalString(value: unknown, limit: number): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, limit) : undefined;
|
||||
}
|
||||
|
||||
export function normalizeBrowserRecordingCrypto(value: unknown): BrowserRecordingCrypto | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.adapterId !== 'string' || !ADAPTER_ID.test(input.adapterId)
|
||||
|| !PROVIDER_KINDS.includes(input.providerKind as BrowserCryptoProviderKind)
|
||||
|| !FAMILIES.includes(input.family as BrowserCryptoFamily)
|
||||
|| typeof input.operation !== 'string' || !OPERATION_ID.test(input.operation)) return undefined;
|
||||
const keyInput = input.key && typeof input.key === 'object' ? input.key as Record<string, unknown> : undefined;
|
||||
const keyKinds: NonNullable<BrowserRecordingCrypto['key']>['kind'][] = ['public', 'private', 'secret', 'unknown'];
|
||||
const key = keyInput && keyKinds.includes(keyInput.kind as NonNullable<BrowserRecordingCrypto['key']>['kind'])
|
||||
? {
|
||||
kind: keyInput.kind as NonNullable<BrowserRecordingCrypto['key']>['kind'],
|
||||
bits: Number.isSafeInteger(keyInput.bits) && Number(keyInput.bits) >= 1 && Number(keyInput.bits) <= 1_048_576
|
||||
? Number(keyInput.bits) : undefined,
|
||||
fingerprint: optionalString(keyInput.fingerprint, 160),
|
||||
}
|
||||
: undefined;
|
||||
const stateInput = input.state && typeof input.state === 'object' ? input.state as Record<string, unknown> : undefined;
|
||||
const stateModels: NonNullable<BrowserRecordingCrypto['state']>['model'][] = [
|
||||
'stateless', 'receiver', 'session', 'stream', 'async-ready',
|
||||
];
|
||||
const phases: NonNullable<BrowserRecordingCrypto['state']>['phase'][] = ['create', 'init', 'update', 'final', 'one-shot'];
|
||||
const state = stateInput && stateModels.includes(stateInput.model as NonNullable<BrowserRecordingCrypto['state']>['model'])
|
||||
? {
|
||||
model: stateInput.model as NonNullable<BrowserRecordingCrypto['state']>['model'],
|
||||
correlationId: optionalString(stateInput.correlationId, 160),
|
||||
phase: phases.includes(stateInput.phase as NonNullable<BrowserRecordingCrypto['state']>['phase'])
|
||||
? stateInput.phase as NonNullable<BrowserRecordingCrypto['state']>['phase']
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
adapterId: input.adapterId,
|
||||
providerKind: input.providerKind as BrowserCryptoProviderKind,
|
||||
family: input.family as BrowserCryptoFamily,
|
||||
operation: input.operation,
|
||||
algorithm: optionalString(input.algorithm, 240),
|
||||
mode: optionalString(input.mode, 120),
|
||||
padding: optionalString(input.padding, 120),
|
||||
inputEncoding: ENCODINGS.includes(input.inputEncoding as BrowserPageCallableValueEncoding)
|
||||
? input.inputEncoding as BrowserPageCallableValueEncoding : undefined,
|
||||
outputEncoding: ENCODINGS.includes(input.outputEncoding as BrowserPageCallableValueEncoding)
|
||||
? input.outputEncoding as BrowserPageCallableValueEncoding : undefined,
|
||||
state,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
export function cryptoEventLabel(event: Pick<BrowserRecordingEvent, 'operation' | 'crypto'>): string {
|
||||
const crypto = event.crypto;
|
||||
if (!crypto) return event.operation;
|
||||
return `${cryptoAdapterLabel(crypto.adapterId)} ${crypto.algorithm || crypto.operation || event.operation}`;
|
||||
}
|
||||
|
||||
export function isForwardCryptoEvent(event: BrowserRecordingEvent): boolean {
|
||||
if (event.kind !== 'crypto' || !event.crypto) return false;
|
||||
const operation = `${event.operation} ${event.crypto.operation}`.toLowerCase();
|
||||
if (operation.includes('decrypt') || operation.includes('verify') || operation.includes('decode')) return false;
|
||||
return ['encrypt', 'sign', 'digest', 'hmac', 'sha', 'md5', 'ripemd', 'pbkdf', 'evpkdf']
|
||||
.some((name) => operation.includes(name));
|
||||
}
|
||||
|
||||
export function cryptoDeepCaptureMatcher(event: Pick<
|
||||
BrowserRecordingEvent,
|
||||
'kind' | 'crypto' | 'wrapperHandleId' | 'scriptUrl'
|
||||
>): BrowserDeepCaptureMatcher | undefined {
|
||||
if (event.kind !== 'crypto' || !event.crypto || !event.wrapperHandleId) return undefined;
|
||||
return {
|
||||
kind: 'crypto',
|
||||
adapterId: event.crypto.adapterId,
|
||||
operation: event.crypto.operation,
|
||||
wrapperHandleId: event.wrapperHandleId,
|
||||
scriptUrl: event.scriptUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
BrowserRecordingCallArgument,
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingLink,
|
||||
} from '@/types/models';
|
||||
import { inferBrowserTransformProfiles } from './inference';
|
||||
import { buildRecordingLinks } from '@/features/browser-recording/timeline';
|
||||
|
||||
function event(overrides: Partial<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): BrowserRecordingEvent {
|
||||
return {
|
||||
timestamp: 1_000 + overrides.sequence,
|
||||
recordingId: 'recording-1',
|
||||
traceId: 'trace-1',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
sensitiveCaptured: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function link(overrides: Pick<BrowserRecordingLink, 'id' | 'fromEventId' | 'fromPath' | 'toEventId' | 'toPath'>): BrowserRecordingLink {
|
||||
return { traceId: 'trace-1', kind: 'value', confidence: 'exact', ...overrides };
|
||||
}
|
||||
|
||||
const safeArguments: BrowserRecordingCallArgument[] = [
|
||||
{ index: 0, role: 'data', dataType: 'string', byteLength: 52, replaceable: true, retained: true },
|
||||
{ index: 1, role: 'key', dataType: 'Object', byteLength: 16, replaceable: false, retained: true },
|
||||
{
|
||||
index: 2,
|
||||
role: 'options',
|
||||
dataType: 'Object',
|
||||
replaceable: false,
|
||||
retained: true,
|
||||
summary: 'mode=CBC padding=Pkcs7 ivBytes=16',
|
||||
},
|
||||
];
|
||||
|
||||
const cryptoJsAES = {
|
||||
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', algorithm: 'AES.encrypt',
|
||||
} as const;
|
||||
const webCryptoAES = {
|
||||
adapterId: 'webcrypto', providerKind: 'native', family: 'symmetric', operation: 'encrypt', algorithm: 'AES-GCM',
|
||||
} as const;
|
||||
const cryptoJsHmac = {
|
||||
adapterId: 'cryptojs', providerKind: 'library', family: 'mac', operation: 'HmacSHA256', algorithm: 'HmacSHA256',
|
||||
} as const;
|
||||
|
||||
describe('browser profile inference', () => {
|
||||
it('turns a JSEncrypt RSA result mapped to a form field into a ready profile', () => {
|
||||
const rsa = event({
|
||||
id: 'rsa-1', sequence: 1, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: {
|
||||
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
|
||||
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
|
||||
key: { kind: 'public', bits: 1024, fingerprint: 'key-fingerprint' },
|
||||
},
|
||||
callHandleId: 'rsa-handle', callableCapable: true,
|
||||
arguments: [{ index: 0, role: 'data', dataType: 'string', byteLength: 44, replaceable: true, retained: true }],
|
||||
inputs: [{ path: '$input', fingerprint: 'plain-json', encoding: 'text', byteLength: 44 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-rsa', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/rsa.php',
|
||||
inputs: [{ path: '$body:form.data', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [rsa, request],
|
||||
links: [link({ id: 'rsa-link', fromEventId: rsa.id, fromPath: '$output', toEventId: request.id, toPath: '$body:form.data' })],
|
||||
});
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
status: 'ready',
|
||||
request: {
|
||||
destination: 'body.data',
|
||||
serialization: 'form-field',
|
||||
mappings: [{ sourceEventId: 'rsa-1', destination: 'body.data', serialization: 'form-field' }],
|
||||
},
|
||||
source: {
|
||||
eventId: 'rsa-1',
|
||||
callHandleId: 'rsa-handle',
|
||||
crypto: { adapterId: 'jsencrypt', algorithm: 'RSA', padding: 'PKCS1-v1_5' },
|
||||
},
|
||||
confidence: { level: 'high', score: 100 },
|
||||
});
|
||||
expect(candidate.summary).toContain('JSEncrypt RSA');
|
||||
});
|
||||
|
||||
it('turns an exact CryptoJS-to-JSON-field link into a high-confidence capture candidate', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 52 }],
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
|
||||
inputs: [{ path: '$body:json.encryptedData', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const candidates = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
||||
events: [crypto, request],
|
||||
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath: '$body:json.encryptedData' })],
|
||||
});
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]).toMatchObject({
|
||||
status: 'ready',
|
||||
request: { destination: 'body.encryptedData', serialization: 'json-field' },
|
||||
source: { eventId: 'crypto-1', callHandleId: 'handle-1', arguments: safeArguments },
|
||||
confidence: { level: 'high', score: 100 },
|
||||
});
|
||||
expect(candidates[0].evidence.some((item) => item.kind === 'exact-value' && item.strength === 'proven')).toBe(true);
|
||||
expect(candidates[0].aiContext.valuePolicy).toBe('metadata-only');
|
||||
});
|
||||
|
||||
it('follows a bounded exact-value chain through an intermediate encoder', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
outputs: [{ path: '$output', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
|
||||
});
|
||||
const encoder = event({
|
||||
id: 'encode-1', sequence: 2, kind: 'transform', operation: 'base64.encode',
|
||||
inputs: [{ path: '$input', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 3, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const candidates = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [crypto, encoder, request],
|
||||
links: [
|
||||
link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output', toEventId: encoder.id, toPath: '$input' }),
|
||||
link({ id: 'link-2', fromEventId: encoder.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
|
||||
],
|
||||
});
|
||||
|
||||
const cryptoCandidate = candidates.find((item) => item.source.eventId === crypto.id);
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(cryptoCandidate?.request.destination).toBe('header.x-signature');
|
||||
expect(cryptoCandidate?.evidence.filter((item) => item.kind === 'exact-value')).toHaveLength(2);
|
||||
expect(cryptoCandidate?.flow).toContain('1 个中间转换');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['$body:form.encryptedData', 'body.encryptedData'],
|
||||
['$query.signature', 'query.signature'],
|
||||
])('maps generic serialized request evidence %s to %s', (toPath, destination) => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/session',
|
||||
inputs: [{ path: toPath, fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [crypto, request],
|
||||
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath })],
|
||||
});
|
||||
|
||||
expect(candidate.request.destination).toBe(destination);
|
||||
expect(candidate.confidence.level).toBe('high');
|
||||
expect(candidate.request.serialization).toBe(toPath.startsWith('$body:form.') ? 'form-field' : 'query');
|
||||
});
|
||||
|
||||
it('keeps a same-trace temporal guess low-confidence and never includes captured values in AI context', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
|
||||
inputPreview: 'plain-password', outputPreview: 'secret-signature', arguments: safeArguments,
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/api/login?token=secret-query-value&mode=fast#private-fragment',
|
||||
inputPreview: '{"password":"plain-password"}',
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [crypto, request], links: [] });
|
||||
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.confidence.level).toBe('low');
|
||||
expect(JSON.stringify(candidate)).not.toContain('plain-password');
|
||||
expect(JSON.stringify(candidate)).not.toContain('secret-signature');
|
||||
expect(JSON.stringify(candidate.aiContext)).not.toContain('secret-query-value');
|
||||
expect(JSON.stringify(candidate.aiContext)).not.toContain('private-fragment');
|
||||
expect(candidate.aiContext.request.url).toBe('https://example.test/api/login?mode&token');
|
||||
expect(candidate.summary).toContain('可继续捕获完整页面业务封装');
|
||||
expect(candidate.missing[0].label).not.toMatch(/更短|重新录制一次|操作太长/);
|
||||
});
|
||||
|
||||
it('continues from an unknown request boundary even when no known crypto library is visible', () => {
|
||||
const request = event({
|
||||
id: 'opaque-request', sequence: 1, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/opaque', stack: 'at pack (https://example.test/chunk-a.js:1:42)',
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [request], links: [],
|
||||
});
|
||||
|
||||
expect(candidate).toMatchObject({ status: 'capture-required', source: { operation: 'unknown-business-envelope' } });
|
||||
expect(candidate.summary).toContain('算法或库未知不影响继续捕获');
|
||||
expect(candidate.aiContext.requiredDecision).toBe('capture-business-callable');
|
||||
expect(candidate.capturePlan).toMatchObject({ matcherEventId: request.id, sourceCount: 1 });
|
||||
});
|
||||
|
||||
it('treats a Worker round trip as correlated evidence rather than an exact value proof', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
|
||||
outputs: [{ path: '$output', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
|
||||
});
|
||||
const send = event({
|
||||
id: 'worker-send', sequence: 2, kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: 'channel-1',
|
||||
inputs: [{ path: '$message', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
|
||||
});
|
||||
const receive = event({
|
||||
id: 'worker-receive', sequence: 3, kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: 'channel-1',
|
||||
outputs: [{ path: '$message', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/submit',
|
||||
inputs: [{ path: '$body:json.payload', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
|
||||
});
|
||||
const links: BrowserRecordingLink[] = [
|
||||
link({ id: 'value-in', fromEventId: crypto.id, fromPath: '$output', toEventId: send.id, toPath: '$message' }),
|
||||
{ id: 'channel', traceId: 'trace-1', kind: 'channel', confidence: 'correlated', fromEventId: send.id, fromPath: '$message', toEventId: receive.id, toPath: '$message' },
|
||||
link({ id: 'value-out', fromEventId: receive.id, fromPath: '$message', toEventId: request.id, toPath: '$body:json.payload' }),
|
||||
];
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [crypto, send, receive, request], links,
|
||||
});
|
||||
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.request.destination).toBe('body.payload');
|
||||
expect(candidate.evidence.some((item) => item.kind === 'message-boundary' && item.strength === 'supported')).toBe(true);
|
||||
});
|
||||
|
||||
it('groups hybrid AES and RSA outputs into one request graph and refuses unsafe independent replay', () => {
|
||||
const sources = [
|
||||
event({
|
||||
id: 'aes-data', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'aes-handle', callableCapable: true, arguments: safeArguments,
|
||||
stack: 'at aes (https://example.test/vendor/aes.js:1:1)\n at _0xPacket (https://example.test/app.js:40:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 }],
|
||||
}),
|
||||
event({
|
||||
id: 'rsa-key', sequence: 2, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
|
||||
callHandleId: 'rsa-key-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
|
||||
stack: 'at rsa (https://example.test/vendor/rsa.js:1:1)\n at _0xPacket (https://example.test/app.js:51:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 }],
|
||||
}),
|
||||
event({
|
||||
id: 'rsa-iv', sequence: 3, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
|
||||
callHandleId: 'rsa-iv-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
|
||||
stack: 'at rsa (https://example.test/vendor/rsa.js:8:1)\n at _0xPacket (https://example.test/app.js:58:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 }],
|
||||
}),
|
||||
];
|
||||
const request = event({
|
||||
id: 'hybrid-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/aesrsa.php',
|
||||
inputs: [
|
||||
{ path: '$body:json.encryptedData', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 },
|
||||
{ path: '$body:json.encryptedKey', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 },
|
||||
{ path: '$body:json.encryptedIv', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 },
|
||||
],
|
||||
});
|
||||
const links = sources.map((source, index) => link({
|
||||
id: `hybrid-link-${index}`,
|
||||
fromEventId: source.id,
|
||||
fromPath: source.outputs[0].path,
|
||||
toEventId: request.id,
|
||||
toPath: request.inputs[index].path,
|
||||
}));
|
||||
|
||||
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [...sources, request], links });
|
||||
expect(candidate.sources).toHaveLength(3);
|
||||
expect(candidate.request.mappings.map((item) => item.destination)).toEqual([
|
||||
'body.encryptedData', 'body.encryptedKey', 'body.encryptedIv',
|
||||
]);
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.summary).toContain('3 个密码调用');
|
||||
expect(candidate.missing[0].label).toContain('随机 Key、IV、Nonce');
|
||||
expect(candidate.capturePlan).toMatchObject({
|
||||
matcherEventId: sources[0].id,
|
||||
sourceCount: 3,
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
});
|
||||
expect(candidate.capturePlan?.frameHints[0]).toMatchObject({ functionName: '_0xPacket', support: 3 });
|
||||
});
|
||||
|
||||
it('keeps a stateful signature session as one request source and exposes its ordered stages', () => {
|
||||
const crypto = (operation: string, phase: 'create' | 'update' | 'final') => ({
|
||||
adapterId: 'jsrsasign', providerKind: 'library' as const, family: 'signature' as const, operation,
|
||||
algorithm: 'SHA256withRSA',
|
||||
state: { model: 'session' as const, correlationId: 'signature-session-1', phase },
|
||||
});
|
||||
const create = event({
|
||||
id: 'signature-create', sequence: 1, kind: 'crypto', operation: 'Signature.create', crypto: crypto('Signature.create', 'create'),
|
||||
});
|
||||
const update = event({
|
||||
id: 'signature-update', sequence: 2, kind: 'crypto', operation: 'Signature.updateString', crypto: crypto('Signature.updateString', 'update'),
|
||||
});
|
||||
const final = event({
|
||||
id: 'signature-final', sequence: 3, kind: 'crypto', operation: 'Signature.sign', crypto: crypto('Signature.sign', 'final'),
|
||||
outputs: [{ path: '$output', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'signed-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/signed',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const links: BrowserRecordingLink[] = [
|
||||
{ id: 'state-create-update', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: create.id, fromPath: '$state.create', toEventId: update.id, toPath: '$state.update' },
|
||||
{ id: 'state-update-final', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: update.id, fromPath: '$state.update', toEventId: final.id, toPath: '$state.final' },
|
||||
link({ id: 'signature-request', fromEventId: final.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
|
||||
];
|
||||
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [create, update, final, request], links,
|
||||
});
|
||||
expect(candidate.sources).toHaveLength(1);
|
||||
expect(candidate.source.eventId).toBe(final.id);
|
||||
expect(candidate.request.destination).toBe('header.x-signature');
|
||||
expect(candidate.evidence).toContainEqual(expect.objectContaining({
|
||||
kind: 'state-sequence', eventIds: [create.id, update.id, final.id],
|
||||
}));
|
||||
expect(candidate.capturePlan).toMatchObject({ sourceCount: 3 });
|
||||
});
|
||||
|
||||
it('traces canonical JSON through a signature and Axios into a request header', () => {
|
||||
const canonical = event({
|
||||
id: 'canonical-json', sequence: 1, kind: 'transform', operation: 'JSON.stringify',
|
||||
transform: { category: 'serializer', provider: 'native', phase: 'output' },
|
||||
inputs: [{ path: '$input.account', fingerprint: 'account', encoding: 'text', byteLength: 5 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
|
||||
});
|
||||
const signature = event({
|
||||
id: 'signature', sequence: 2, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
|
||||
callHandleId: 'signature-handle', callableCapable: true, arguments: safeArguments,
|
||||
inputs: [{ path: '$input', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const axios = event({
|
||||
id: 'axios', sequence: 3, kind: 'transform', operation: 'axios.request',
|
||||
transform: { category: 'request-builder', provider: 'axios', phase: 'boundary' },
|
||||
inputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
outputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request', sequence: 4, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/order',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const events = [canonical, signature, axios, request];
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events, links: buildRecordingLinks(events),
|
||||
});
|
||||
|
||||
expect(candidate.sources).toHaveLength(1);
|
||||
expect(candidate.request).toMatchObject({ destination: 'header.x-signature', serialization: 'header' });
|
||||
expect(candidate.evidence).toContainEqual(expect.objectContaining({
|
||||
kind: 'transform-lineage', strength: 'proven', eventIds: [canonical.id, signature.id],
|
||||
}));
|
||||
expect(candidate.flow).toContain('1 个输入准备步骤');
|
||||
expect(candidate.flow).toContain('1 个中间转换');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import type {
|
||||
BrowserProfileInferenceCandidate,
|
||||
BrowserProfileInferenceEvidence,
|
||||
BrowserProfileInferenceMissingStep,
|
||||
BrowserProfileInferenceSerialization,
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingLink,
|
||||
BrowserTarget,
|
||||
} from '@/types/models';
|
||||
import { cryptoEventLabel, isForwardCryptoEvent } from '@/features/browser-crypto/model';
|
||||
import { inferBusinessFrameHints } from './stack-hints';
|
||||
|
||||
const MAX_LINK_DEPTH = 8;
|
||||
const MAX_CANDIDATES = 16;
|
||||
|
||||
export interface BrowserProfileInferenceInput {
|
||||
target: BrowserTarget;
|
||||
events: BrowserRecordingEvent[];
|
||||
links: BrowserRecordingLink[];
|
||||
}
|
||||
|
||||
interface LinkedSource {
|
||||
event: BrowserRecordingEvent;
|
||||
links: BrowserRecordingLink[];
|
||||
stateLinks: BrowserRecordingLink[];
|
||||
stateEvents: BrowserRecordingEvent[];
|
||||
inputLinks: BrowserRecordingLink[];
|
||||
inputEvents: BrowserRecordingEvent[];
|
||||
}
|
||||
|
||||
function isRequestEvent(event: BrowserRecordingEvent): boolean {
|
||||
return ['fetch', 'xhr', 'form', 'beacon'].includes(event.kind) && event.operation === 'request';
|
||||
}
|
||||
|
||||
function isCandidateSource(event: BrowserRecordingEvent): boolean {
|
||||
return isForwardCryptoEvent(event);
|
||||
}
|
||||
|
||||
function requestMapping(path?: string): { destination?: string; serialization?: BrowserProfileInferenceSerialization } {
|
||||
if (!path) return {};
|
||||
if (path === '$body' || path === '$body:json') return { destination: 'body', serialization: 'raw-body' };
|
||||
if (path.startsWith('$body:json.')) {
|
||||
return { destination: `body.${path.slice('$body:json.'.length)}`, serialization: 'json-field' };
|
||||
}
|
||||
if (path.startsWith('$body:form.')) {
|
||||
return { destination: `body.${path.slice('$body:form.'.length)}`, serialization: 'form-field' };
|
||||
}
|
||||
if (path.startsWith('$body.')) return { destination: `body.${path.slice('$body.'.length)}`, serialization: 'json-field' };
|
||||
if (path.startsWith('$headers.')) return { destination: `header.${path.slice('$headers.'.length)}`, serialization: 'header' };
|
||||
if (path.startsWith('$query.')) return { destination: `query.${path.slice('$query.'.length)}`, serialization: 'query' };
|
||||
return {};
|
||||
}
|
||||
|
||||
function requestLabel(event: BrowserRecordingEvent): string {
|
||||
const method = event.method || 'GET';
|
||||
if (!event.url) return method;
|
||||
try {
|
||||
return `${method} ${new URL(event.url, 'https://recording.invalid').pathname || '/'}`;
|
||||
} catch {
|
||||
return `${method} ${event.url}`;
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrlMetadata(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value, 'https://recording.invalid');
|
||||
const relative = !/^[a-z][a-z\d+.-]*:\/\//i.test(value);
|
||||
const queryKeys = [...new Set([...parsed.searchParams.keys()])].sort();
|
||||
const query = queryKeys.length ? `?${queryKeys.map(encodeURIComponent).join('&')}` : '';
|
||||
return relative ? `${parsed.pathname}${query}` : `${parsed.origin}${parsed.pathname}${query}`;
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0].slice(0, 2_048);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(event: BrowserRecordingEvent): string {
|
||||
return event.kind === 'crypto' ? cryptoEventLabel(event) : event.operation;
|
||||
}
|
||||
|
||||
function linkedSources(
|
||||
request: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): LinkedSource[] {
|
||||
const output = new Map<string, LinkedSource>();
|
||||
const queue: Array<{ eventId: string; links: BrowserRecordingLink[]; depth: number }> = [
|
||||
{ eventId: request.id, links: [], depth: 0 },
|
||||
];
|
||||
const visitedDepth = new Map<string, number>([[request.id, 0]]);
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (current.depth >= MAX_LINK_DEPTH) continue;
|
||||
for (const link of incoming.get(current.eventId) || []) {
|
||||
if (link.kind === 'state') continue;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== request.traceId || source.sequence >= request.sequence) continue;
|
||||
const chain = [link, ...current.links];
|
||||
if (isCandidateSource(source)) {
|
||||
const previous = output.get(source.id);
|
||||
if (!previous || chain.length < previous.links.length) {
|
||||
output.set(source.id, {
|
||||
event: source,
|
||||
links: chain,
|
||||
...stateSequence(source, eventsById, incoming),
|
||||
...inputLineage(source, eventsById, incoming),
|
||||
});
|
||||
}
|
||||
}
|
||||
const depth = current.depth + 1;
|
||||
if ((visitedDepth.get(source.id) ?? Number.POSITIVE_INFINITY) <= depth) continue;
|
||||
visitedDepth.set(source.id, depth);
|
||||
queue.push({ eventId: source.id, links: chain, depth });
|
||||
}
|
||||
}
|
||||
return [...output.values()];
|
||||
}
|
||||
|
||||
function inputLineage(
|
||||
event: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): Pick<LinkedSource, 'inputLinks' | 'inputEvents'> {
|
||||
const linksById = new Map<string, BrowserRecordingLink>();
|
||||
const events = new Map<string, BrowserRecordingEvent>();
|
||||
const queue: Array<{ event: BrowserRecordingEvent; depth: number }> = [{ event, depth: 0 }];
|
||||
const visited = new Set<string>([event.id]);
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (current.depth >= MAX_LINK_DEPTH) continue;
|
||||
for (const link of incoming.get(current.event.id) || []) {
|
||||
if (link.kind !== 'value' || link.confidence !== 'exact') continue;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== event.traceId || source.kind !== 'transform' || visited.has(source.id)) continue;
|
||||
visited.add(source.id);
|
||||
linksById.set(link.id, link);
|
||||
events.set(source.id, source);
|
||||
queue.push({ event: source, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputLinks: [...linksById.values()].sort((left, right) => (
|
||||
(eventsById.get(left.fromEventId)?.sequence || 0) - (eventsById.get(right.fromEventId)?.sequence || 0)
|
||||
)),
|
||||
inputEvents: [...events.values()].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
function stateSequence(
|
||||
event: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): Pick<LinkedSource, 'stateLinks' | 'stateEvents'> {
|
||||
const links: BrowserRecordingLink[] = [];
|
||||
const events: BrowserRecordingEvent[] = [event];
|
||||
const visited = new Set<string>([event.id]);
|
||||
let current = event;
|
||||
while (links.length < MAX_LINK_DEPTH) {
|
||||
const link = (incoming.get(current.id) || [])
|
||||
.filter((item) => item.kind === 'state')
|
||||
.sort((left, right) => {
|
||||
const leftSequence = eventsById.get(left.fromEventId)?.sequence ?? -1;
|
||||
const rightSequence = eventsById.get(right.fromEventId)?.sequence ?? -1;
|
||||
return rightSequence - leftSequence;
|
||||
})[0];
|
||||
if (!link) break;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== event.traceId || visited.has(source.id)) break;
|
||||
visited.add(source.id);
|
||||
links.unshift(link);
|
||||
events.unshift(source);
|
||||
current = source;
|
||||
}
|
||||
return { stateLinks: links, stateEvents: events };
|
||||
}
|
||||
|
||||
function temporalSource(
|
||||
request: BrowserRecordingEvent,
|
||||
events: BrowserRecordingEvent[],
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): LinkedSource | undefined {
|
||||
const source = events
|
||||
.filter((event) => event.traceId === request.traceId && event.sequence < request.sequence && isCandidateSource(event))
|
||||
.sort((left, right) => right.sequence - left.sequence)[0];
|
||||
return source ? {
|
||||
event: source,
|
||||
links: [],
|
||||
...stateSequence(source, eventsById, incoming),
|
||||
...inputLineage(source, eventsById, incoming),
|
||||
} : undefined;
|
||||
}
|
||||
|
||||
function confidenceLevel(score: number): 'high' | 'medium' | 'low' {
|
||||
if (score >= 80) return 'high';
|
||||
if (score >= 55) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function capturePlan(
|
||||
matcherEventId: string,
|
||||
events: BrowserRecordingEvent[],
|
||||
expectedDestinations: Array<string | undefined>,
|
||||
) {
|
||||
return {
|
||||
matcherEventId,
|
||||
frameHints: inferBusinessFrameHints(events),
|
||||
expectedDestinations: expectedDestinations.filter((item): item is string => Boolean(item)),
|
||||
sourceCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
source: LinkedSource,
|
||||
): BrowserProfileInferenceCandidate {
|
||||
const exact = source.links.length > 0 && source.links.every((link) => link.confidence === 'exact');
|
||||
const finalLink = source.links.at(-1);
|
||||
const { destination, serialization } = requestMapping(finalLink?.toPath);
|
||||
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
|
||||
const replayReady = hasCallable && Boolean(destination) && source.links.length === 1;
|
||||
const argumentRoles = source.event.arguments || [];
|
||||
const evidence: BrowserProfileInferenceEvidence[] = [{
|
||||
id: `evidence-request-${request.id}`,
|
||||
kind: 'request-boundary',
|
||||
strength: 'proven',
|
||||
label: `请求边界:${requestLabel(request)}`,
|
||||
eventIds: [request.id],
|
||||
toPath: finalLink?.toPath,
|
||||
}];
|
||||
source.links.forEach((link, index) => evidence.push({
|
||||
id: `evidence-link-${link.id || `${source.event.id}-${request.id}-${index}`}`,
|
||||
kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary',
|
||||
strength: link.confidence === 'exact' ? 'proven' : 'supported',
|
||||
label: link.confidence === 'correlated'
|
||||
? '同一 Worker / MessagePort 通道的发送与接收已关联'
|
||||
: index === source.links.length - 1 && destination
|
||||
? `输出指纹精确进入 ${destination}`
|
||||
: `中间值指纹精确匹配 ${link.fromPath} -> ${link.toPath}`,
|
||||
eventIds: [link.fromEventId, link.toEventId],
|
||||
fromPath: link.fromPath,
|
||||
toPath: link.toPath,
|
||||
}));
|
||||
if (source.stateLinks.length) {
|
||||
const phases = source.stateEvents
|
||||
.map((event) => event.crypto?.state?.phase)
|
||||
.filter((phase): phase is NonNullable<NonNullable<BrowserRecordingEvent['crypto']>['state']>['phase'] => Boolean(phase));
|
||||
evidence.push({
|
||||
id: `evidence-state-${source.event.id}`,
|
||||
kind: 'state-sequence',
|
||||
strength: 'supported',
|
||||
label: `同一密码会话已关联 ${phases.join(' -> ') || `${source.stateEvents.length} 个阶段`}`,
|
||||
eventIds: source.stateEvents.map((event) => event.id),
|
||||
fromPath: source.stateLinks[0]?.fromPath,
|
||||
toPath: source.stateLinks.at(-1)?.toPath,
|
||||
});
|
||||
}
|
||||
source.inputLinks.forEach((link, index) => {
|
||||
const transform = source.inputEvents.find((event) => event.id === link.fromEventId);
|
||||
const category = transform?.transform?.category;
|
||||
const label = category === 'canonicalization'
|
||||
? `已关联规范化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
|
||||
: category === 'request-builder'
|
||||
? `已关联请求准备步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
|
||||
: `已关联序列化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`;
|
||||
evidence.push({
|
||||
id: `evidence-input-transform-${link.id || `${source.event.id}-${index}`}`,
|
||||
kind: 'transform-lineage',
|
||||
strength: 'proven',
|
||||
label,
|
||||
eventIds: [link.fromEventId, link.toEventId],
|
||||
fromPath: link.fromPath,
|
||||
toPath: link.toPath,
|
||||
});
|
||||
});
|
||||
evidence.push({
|
||||
id: `evidence-order-${source.event.id}-${request.id}`,
|
||||
kind: 'trace-order',
|
||||
strength: 'supported',
|
||||
label: '加密调用与请求位于同一业务 Trace,且调用发生在请求之前',
|
||||
eventIds: [source.event.id, request.id],
|
||||
});
|
||||
if (hasCallable) evidence.push({
|
||||
id: `evidence-callable-${source.event.id}`,
|
||||
kind: 'callable',
|
||||
strength: 'proven',
|
||||
label: '页面仍保留本次调用的原函数、receiver 与固定参数模板',
|
||||
eventIds: [source.event.id],
|
||||
});
|
||||
|
||||
let score = 20;
|
||||
if (exact) score += 40;
|
||||
if (destination) score += 10;
|
||||
if (hasCallable) score += 15;
|
||||
if (argumentRoles.length) score += 10;
|
||||
score += 5;
|
||||
score = Math.min(100, score);
|
||||
|
||||
const missing: BrowserProfileInferenceMissingStep[] = [];
|
||||
let status: BrowserProfileInferenceCandidate['status'];
|
||||
if (!exact || !destination) {
|
||||
status = 'capture-required';
|
||||
missing.push({
|
||||
kind: 'business-callable',
|
||||
label: source.links.some((link) => link.confidence === 'correlated')
|
||||
? '已关联页面与 Worker 消息链;继续捕获请求前的上层业务函数,即可保留 Worker 内部状态与完整报文封装'
|
||||
: '字段级值链尚不完整;继续捕获请求前的上层业务函数,不需要重新录制更长的操作',
|
||||
action: 'capture-business-function',
|
||||
});
|
||||
} else if (replayReady) {
|
||||
status = 'ready';
|
||||
} else if (source.event.kind === 'crypto') {
|
||||
status = 'capture-required';
|
||||
missing.push({
|
||||
kind: 'business-callable',
|
||||
label: '已定位低层加密调用;还需捕获上层业务函数,才能保留序列化、动态参数与完整报文封装',
|
||||
action: 'capture-business-function',
|
||||
});
|
||||
} else {
|
||||
status = 'mapping-required';
|
||||
missing.push({
|
||||
kind: 'input-mapping',
|
||||
label: '需要确认明文输入在逻辑请求中的来源',
|
||||
action: 'select-input',
|
||||
});
|
||||
}
|
||||
|
||||
const candidateId = `candidate-${source.event.id}-${request.id}`;
|
||||
const sourceName = sourceLabel(source.event);
|
||||
const requestName = requestLabel(request);
|
||||
const requiredDecision = status === 'capture-required' ? 'capture-business-callable'
|
||||
: status === 'mapping-required' ? 'map-input'
|
||||
: status === 'ready' ? 'map-input'
|
||||
: destination ? 'map-input' : 'map-output';
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
destination,
|
||||
serialization,
|
||||
mappings: [{ sourceEventId: source.event.id, destination, serialization }],
|
||||
},
|
||||
source: {
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
callHandleId: source.event.callHandleId,
|
||||
arguments: argumentRoles,
|
||||
destination,
|
||||
serialization,
|
||||
},
|
||||
sources: [{
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
callHandleId: source.event.callHandleId,
|
||||
arguments: argumentRoles,
|
||||
destination,
|
||||
serialization,
|
||||
}],
|
||||
status,
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: replayReady
|
||||
? `已确认 ${sourceName} 的输出进入 ${destination},可生成明文网关`
|
||||
: exact && destination
|
||||
? `已确认 ${sourceName} 的输出进入 ${destination}`
|
||||
: `已定位 ${sourceName} 与 ${requestName},可继续捕获完整页面业务封装`,
|
||||
flow: [
|
||||
'明文输入(待确认)',
|
||||
...(source.inputEvents.length ? [`${source.inputEvents.length} 个输入准备步骤`] : []),
|
||||
sourceName,
|
||||
...(source.links.length > 1 ? [`${source.links.length - 1} 个中间转换`] : []),
|
||||
destination ? `${requestName} · ${destination}` : requestName,
|
||||
],
|
||||
pipeline: [
|
||||
{ id: `${candidateId}-input`, kind: 'context.read', label: '读取明文输入', source: '待确认' },
|
||||
{
|
||||
id: `${candidateId}-call`,
|
||||
kind: 'page.call',
|
||||
label: sourceName,
|
||||
callHandleId: source.event.callHandleId,
|
||||
},
|
||||
{
|
||||
id: `${candidateId}-output`,
|
||||
kind: 'output.write',
|
||||
label: destination ? `写入 ${destination}` : '确认请求输出位置',
|
||||
destination,
|
||||
},
|
||||
],
|
||||
evidence,
|
||||
missing,
|
||||
capturePlan: status === 'capture-required'
|
||||
? capturePlan(
|
||||
source.event.id,
|
||||
[...new Map([...source.inputEvents, ...source.stateEvents].map((event) => [event.id, event])).values()],
|
||||
[destination],
|
||||
)
|
||||
: undefined,
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
destination,
|
||||
serialization,
|
||||
},
|
||||
source: {
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
scriptUrl: safeUrlMetadata(source.event.scriptUrl),
|
||||
arguments: argumentRoles,
|
||||
},
|
||||
sources: [{
|
||||
eventId: source.event.id,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
destination,
|
||||
}],
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildUnknownBoundaryCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
): BrowserProfileInferenceCandidate {
|
||||
const requestName = requestLabel(request);
|
||||
const candidateId = `candidate-boundary-${request.id}`;
|
||||
const stackAvailable = Boolean(request.stack || request.scriptUrl);
|
||||
const evidence: BrowserProfileInferenceEvidence[] = [{
|
||||
id: `evidence-request-${request.id}`,
|
||||
kind: 'request-boundary',
|
||||
strength: 'proven',
|
||||
label: `真实请求边界:${requestName}`,
|
||||
eventIds: [request.id],
|
||||
}];
|
||||
if (stackAvailable) evidence.push({
|
||||
id: `evidence-stack-${request.id}`,
|
||||
kind: 'heuristic',
|
||||
strength: 'supported',
|
||||
label: '请求发生时保留了有界页面调用来源,可直接进入业务函数捕获',
|
||||
eventIds: [request.id],
|
||||
});
|
||||
const source = {
|
||||
eventId: request.id,
|
||||
kind: request.kind,
|
||||
operation: 'unknown-business-envelope',
|
||||
arguments: [],
|
||||
};
|
||||
const score = stackAvailable ? 45 : 35;
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
mappings: [],
|
||||
},
|
||||
source,
|
||||
sources: [source],
|
||||
status: 'capture-required',
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: `已定位 ${requestName} 的真实发送边界;算法或库未知不影响继续捕获`,
|
||||
flow: ['明文输入(待定位)', '页面业务封装(待捕获)', requestName],
|
||||
pipeline: [
|
||||
{ id: `${candidateId}-input`, kind: 'context.read', label: '读取明文输入', source: '由业务函数参数确认' },
|
||||
{ id: `${candidateId}-call`, kind: 'page.call', label: '页面业务封装' },
|
||||
{ id: `${candidateId}-output`, kind: 'output.write', label: '写入真实请求' },
|
||||
],
|
||||
evidence,
|
||||
missing: [{
|
||||
kind: 'business-callable',
|
||||
label: '没有发现可见的已知密码库调用;重复一次当前操作,插件会在真实请求边界暂停并推荐上层页面函数',
|
||||
action: 'capture-business-function',
|
||||
}],
|
||||
capturePlan: capturePlan(request.id, [request], []),
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
},
|
||||
source: {
|
||||
eventId: request.id,
|
||||
kind: request.kind,
|
||||
operation: 'unknown-business-envelope',
|
||||
scriptUrl: safeUrlMetadata(request.scriptUrl),
|
||||
arguments: [],
|
||||
},
|
||||
sources: [{ eventId: request.id, operation: 'unknown-business-envelope' }],
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision: 'capture-business-callable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestGraphCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
sources: LinkedSource[],
|
||||
): BrowserProfileInferenceCandidate {
|
||||
if (sources.length === 1) return buildCandidate(target, request, sources[0]);
|
||||
const members = [...sources]
|
||||
.sort((left, right) => left.event.sequence - right.event.sequence || left.event.id.localeCompare(right.event.id))
|
||||
.map((source) => buildCandidate(target, request, source));
|
||||
const primary = members[0];
|
||||
const graphSources = members.map((member) => member.source);
|
||||
const mappings = graphSources.map((source) => ({
|
||||
sourceEventId: source.eventId,
|
||||
destination: source.destination,
|
||||
serialization: source.serialization,
|
||||
}));
|
||||
const evidenceById = new Map<string, BrowserProfileInferenceEvidence>();
|
||||
for (const member of members) {
|
||||
for (const item of member.evidence) evidenceById.set(item.id, item);
|
||||
}
|
||||
const evidence = [...evidenceById.values()];
|
||||
const allMapped = graphSources.every((source) => Boolean(source.destination));
|
||||
const score = Math.max(0, Math.min(90, Math.min(...members.map((member) => member.confidence.score)) - 10));
|
||||
const requestName = requestLabel(request);
|
||||
const destinations = graphSources.map((source) => source.destination).filter((item): item is string => Boolean(item));
|
||||
const candidateId = `candidate-graph-${request.id}-${graphSources.map((source) => source.eventId).join('-')}`;
|
||||
const missing: BrowserProfileInferenceMissingStep[] = [{
|
||||
kind: 'business-callable',
|
||||
label: '同一请求包含多个相关密码调用;需要捕获上层业务封装,才能保持随机 Key、IV、Nonce 与各输出字段在每次回放中一致',
|
||||
action: 'capture-business-function',
|
||||
}];
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
mappings,
|
||||
},
|
||||
source: primary.source,
|
||||
sources: graphSources,
|
||||
status: 'capture-required',
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: allMapped
|
||||
? `已确认 ${graphSources.length} 个密码调用分别进入 ${destinations.join('、')},需要保留它们的动态值关系`
|
||||
: `已识别 ${graphSources.length} 个密码调用与 ${requestName} 的请求级数据流`,
|
||||
flow: [
|
||||
'明文与动态参数',
|
||||
`${graphSources.length} 个关联密码调用`,
|
||||
allMapped ? `${requestName} · ${destinations.length} 个字段` : requestName,
|
||||
],
|
||||
pipeline: graphSources.flatMap((source, index) => [
|
||||
{ id: `${candidateId}-input-${index}`, kind: 'context.read' as const, label: `读取调用 ${index + 1} 输入`, source: '待由业务封装确认' },
|
||||
{ id: `${candidateId}-call-${index}`, kind: 'page.call' as const, label: source.crypto ? source.crypto.operation : source.operation, callHandleId: source.callHandleId },
|
||||
{ id: `${candidateId}-output-${index}`, kind: 'output.write' as const, label: source.destination ? `写入 ${source.destination}` : '确认输出位置', destination: source.destination },
|
||||
]),
|
||||
evidence,
|
||||
missing,
|
||||
capturePlan: capturePlan(
|
||||
primary.source.eventId,
|
||||
[...new Map(sources.flatMap((source) => [...source.inputEvents, ...source.stateEvents]).map((event) => [event.id, event])).values()],
|
||||
destinations,
|
||||
),
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
},
|
||||
source: primary.aiContext.source,
|
||||
sources: graphSources.map((source) => ({
|
||||
eventId: source.eventId,
|
||||
operation: source.operation,
|
||||
crypto: source.crypto,
|
||||
destination: source.destination,
|
||||
})),
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision: 'capture-business-callable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function inferBrowserTransformProfiles(input: BrowserProfileInferenceInput): BrowserProfileInferenceCandidate[] {
|
||||
const events = [...input.events].sort((left, right) => left.sequence - right.sequence);
|
||||
const eventsById = new Map(events.map((event) => [event.id, event]));
|
||||
const incoming = new Map<string, BrowserRecordingLink[]>();
|
||||
for (const link of input.links) incoming.set(link.toEventId, [...(incoming.get(link.toEventId) || []), link]);
|
||||
const output: BrowserProfileInferenceCandidate[] = [];
|
||||
for (const request of events.filter(isRequestEvent)) {
|
||||
const exactSources = linkedSources(request, eventsById, incoming);
|
||||
const sources = exactSources.length
|
||||
? exactSources
|
||||
: [temporalSource(request, events, eventsById, incoming)].filter((item): item is LinkedSource => Boolean(item));
|
||||
output.push(sources.length
|
||||
? buildRequestGraphCandidate(input.target, request, sources)
|
||||
: buildUnknownBoundaryCandidate(input.target, request));
|
||||
}
|
||||
return output
|
||||
.sort((left, right) => right.confidence.score - left.confidence.score
|
||||
|| left.source.eventId.localeCompare(right.source.eventId))
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { inferBusinessFrameHints, parseRecordingStack } from './stack-hints';
|
||||
|
||||
describe('recording stack business hints', () => {
|
||||
it('removes recorder and dependency frames while keeping page callers', () => {
|
||||
const frames = parseRecordingStack([
|
||||
'Error',
|
||||
' at recordedEncrypt (chrome-extension://extension/page-recorder-main-world.js:1:10)',
|
||||
' at Object.encrypt (https://example.test/assets/crypto-js.min.js:2:20)',
|
||||
' at buildEnvelope (https://example.test/assets/app.js?v=7:41:9)',
|
||||
' at submitLogin (https://example.test/assets/app.js?v=7:63:5)',
|
||||
].join('\n'));
|
||||
|
||||
expect(frames).toEqual([
|
||||
{ functionName: 'buildEnvelope', url: 'https://example.test/assets/app.js', depth: 0 },
|
||||
{ functionName: 'submitLogin', url: 'https://example.test/assets/app.js', depth: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds the nearest common business ancestor across independently named crypto calls', () => {
|
||||
const hints = inferBusinessFrameHints([
|
||||
{
|
||||
stack: 'at aesPrimitive (https://example.test/aes.js:1:1)\n at assemblePacket (https://example.test/app.js:40:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/aes.js',
|
||||
},
|
||||
{
|
||||
stack: 'at rsaPrimitive (https://example.test/rsa.js:1:1)\n at assemblePacket (https://example.test/app.js:52:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/rsa.js',
|
||||
},
|
||||
{
|
||||
stack: 'at wrapKey (https://example.test/rsa.js:8:1)\n at assemblePacket (https://example.test/app.js:58:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/rsa.js',
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(hints[0]).toMatchObject({ functionName: 'assemblePacket', support: 3, averageDepth: 1 });
|
||||
expect(hints[1]).toMatchObject({ functionName: 'onclick', support: 3 });
|
||||
expect(hints.some((hint) => hint.functionName === 'aesPrimitive')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not manufacture a common frame when one stack is unavailable', () => {
|
||||
expect(inferBusinessFrameHints([
|
||||
{ stack: 'at build (https://example.test/app.js:1:1)' },
|
||||
{ stack: undefined },
|
||||
] as never)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { BrowserBusinessFrameHint, BrowserRecordingEvent } from '@/types/models';
|
||||
|
||||
const MAX_STACK_EVENTS = 8;
|
||||
const MAX_STACK_LINES = 16;
|
||||
const MAX_HINTS = 8;
|
||||
const RECORDER_FUNCTION = /^recorded[A-Z]|^pauseForDeepCapture$|^stackInfo$/;
|
||||
|
||||
interface ParsedStackFrame {
|
||||
functionName: string;
|
||||
url?: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function normalizeFunctionName(value: string): string {
|
||||
const withoutAlias = value.replace(/\s+\[as\s+[^\]]+\]$/, '').replace(/^(?:async\s+|new\s+)/, '').trim();
|
||||
const segments = withoutAlias.split('.');
|
||||
return (segments.at(-1) || withoutAlias || '(anonymous)').replace(/^Object\./, '');
|
||||
}
|
||||
|
||||
function normalizeScriptUrl(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0].slice(0, 2_048) || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyFrame(functionName: string, url?: string): boolean {
|
||||
const value = `${functionName}\n${url || ''}`.toLowerCase();
|
||||
return RECORDER_FUNCTION.test(functionName)
|
||||
|| value.includes('chrome-extension://')
|
||||
|| value.includes('page-recorder-main-world')
|
||||
|| value.includes('/node_modules/')
|
||||
|| value.includes('crypto-js')
|
||||
|| value.includes('jsencrypt')
|
||||
|| value.includes('node-forge')
|
||||
|| value.includes('sm-crypto')
|
||||
|| value.includes('webpack/runtime');
|
||||
}
|
||||
|
||||
export function parseRecordingStack(stack?: string, fallbackUrl?: string): ParsedStackFrame[] {
|
||||
if (!stack) return [];
|
||||
const output: ParsedStackFrame[] = [];
|
||||
for (const rawLine of stack.split('\n').slice(0, MAX_STACK_LINES)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const location = line.match(/((?:(?:https?|file|blob|webpack|chrome-extension):\/\/|\/)[^\s)]+):(\d+):(\d+)\)?$/);
|
||||
if (!location) continue;
|
||||
const url = normalizeScriptUrl(location[1] || fallbackUrl);
|
||||
let prefix = location ? line.slice(0, location.index).trim() : line;
|
||||
prefix = prefix.replace(/^at\s+/, '').replace(/\($/, '').replace(/@$/, '').trim();
|
||||
const functionName = normalizeFunctionName(prefix || '(anonymous)');
|
||||
if (dependencyFrame(functionName, url)) continue;
|
||||
output.push({ functionName, url, depth: output.length });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function inferBusinessFrameHints(
|
||||
events: Array<Pick<BrowserRecordingEvent, 'stack' | 'scriptUrl'>>,
|
||||
): BrowserBusinessFrameHint[] {
|
||||
const bounded = events.slice(0, MAX_STACK_EVENTS);
|
||||
if (!bounded.length) return [];
|
||||
const parsed = bounded.map((event) => parseRecordingStack(event.stack, event.scriptUrl));
|
||||
if (parsed.some((frames) => !frames.length)) return [];
|
||||
const evidence = new Map<string, { functionName: string; url?: string; depths: number[]; events: Set<number> }>();
|
||||
parsed.forEach((frames, eventIndex) => {
|
||||
const seen = new Set<string>();
|
||||
for (const frame of frames) {
|
||||
const key = `${frame.functionName}\n${frame.url || ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const current = evidence.get(key) || {
|
||||
functionName: frame.functionName,
|
||||
url: frame.url,
|
||||
depths: [],
|
||||
events: new Set<number>(),
|
||||
};
|
||||
current.depths.push(frame.depth);
|
||||
current.events.add(eventIndex);
|
||||
evidence.set(key, current);
|
||||
}
|
||||
});
|
||||
const requiredSupport = bounded.length;
|
||||
return [...evidence.values()]
|
||||
.filter((item) => item.events.size === requiredSupport)
|
||||
.map((item) => ({
|
||||
functionName: item.functionName.slice(0, 240),
|
||||
url: item.url?.slice(0, 4_096),
|
||||
support: item.events.size,
|
||||
averageDepth: item.depths.reduce((sum, depth) => sum + depth, 0) / Math.max(1, item.depths.length),
|
||||
}))
|
||||
.sort((left, right) => left.averageDepth - right.averageDepth
|
||||
|| left.functionName.localeCompare(right.functionName)
|
||||
|| (left.url || '').localeCompare(right.url || ''))
|
||||
.slice(0, MAX_HINTS);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
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<void>, success?: string) => Promise<void>;
|
||||
const CHROMIUM_CONTEXT_TOOLS = !import.meta.env.FIREFOX;
|
||||
|
||||
interface RecordingWorkspaceProps {
|
||||
tab?: ActiveTabInfo;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<BrowserRecordingEvent['kind'], string> = {
|
||||
interaction: '页面操作',
|
||||
fetch: 'Fetch',
|
||||
xhr: 'XHR',
|
||||
form: '表单',
|
||||
beacon: 'Beacon',
|
||||
worker: 'Worker',
|
||||
message: '消息通道',
|
||||
websocket: 'WebSocket',
|
||||
crypto: '密码调用',
|
||||
transform: '数据转换',
|
||||
navigation: '浏览器导航',
|
||||
};
|
||||
|
||||
const ARGUMENT_LABELS: Record<BrowserRecordingArgumentRole, string> = {
|
||||
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 <Navigation size={15} />;
|
||||
if (kind === 'interaction') return <Radio size={15} />;
|
||||
if (kind === 'crypto') return <Fingerprint size={15} />;
|
||||
if (kind === 'websocket' || kind === 'worker' || kind === 'message') return <Webhook size={15} />;
|
||||
if (kind === 'fetch' || kind === 'xhr' || kind === 'form' || kind === 'beacon') return <Globe2 size={15} />;
|
||||
return <Braces size={15} />;
|
||||
}
|
||||
|
||||
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<BrowserRecordingSnapshot>();
|
||||
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<BrowserPageCallableExecution>();
|
||||
const [gatewaySuggestion, setGatewaySuggestion] = useState<BrowserTransformSuggestionSeed>();
|
||||
|
||||
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 <section className="recording-section">
|
||||
<div className="recording-heading">
|
||||
<div className="recording-heading__identity"><span>浏览器现场</span><h2>{workspaceMode === 'gateway' ? '浏览器明文网关' : workspaceMode === 'recording' ? '操作与加解密录制' : '业务函数深度捕获'}</h2></div>
|
||||
<div className="recording-mode-switch" role="tablist" aria-label="浏览器现场模式">
|
||||
<button id="recording-mode-tab" type="button" role="tab" aria-controls="recording-mode-panel" aria-selected={workspaceMode === 'recording'} className={workspaceMode === 'recording' ? 'is-selected' : ''} disabled={deepPaused} onClick={() => setWorkspaceMode('recording')}><Radio size={14} />录制</button>
|
||||
{CHROMIUM_CONTEXT_TOOLS && <button id="deep-mode-tab" type="button" role="tab" aria-controls="deep-mode-panel" aria-selected={workspaceMode === 'deep'} className={workspaceMode === 'deep' ? 'is-selected' : ''} onClick={() => setWorkspaceMode('deep')}><Bug size={14} />深度捕获</button>}
|
||||
{CHROMIUM_CONTEXT_TOOLS && <button id="gateway-mode-tab" type="button" role="tab" aria-controls="gateway-mode-panel" aria-selected={workspaceMode === 'gateway'} className={workspaceMode === 'gateway' ? 'is-selected' : ''} disabled={deepPaused} onClick={() => setWorkspaceMode('gateway')}><FileKey2 size={14} />明文网关</button>}
|
||||
</div>
|
||||
<div className={`recording-heading__actions ${workspaceMode === 'recording' ? '' : 'is-inactive'}`} aria-hidden={workspaceMode !== 'recording'}>
|
||||
<span className={`recording-state ${active ? 'is-active' : ''}`}><i />{active ? `${snapshot?.status.count || 0} 个事件` : hasRecording ? '可分析' : '未录制'}</span>
|
||||
{active
|
||||
? <Button variant="ghost" disabled={busy || workspaceMode !== 'recording'} onClick={() => void stop()}><CircleStop size={15} />停止</Button>
|
||||
: <Button variant="primary" disabled={busy || workspaceMode !== 'recording' || !tab?.url?.startsWith('http')} onClick={() => void start()}><Play size={15} />录制一次操作</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recording-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="recording-mode-tab" hidden={workspaceMode !== 'recording'}><div className="recording-controls">
|
||||
<label><Switch checked={captureValues} disabled={active || busy} onCheckedChange={setCaptureValues} /><span><strong>保留短时样本</strong><small>关闭时仅保留本次录制的关联指纹</small></span></label>
|
||||
<span className="recording-summary">{snapshot?.traces.length || 0} 个 Trace · {snapshot?.links.length || 0} 条值关联 · {snapshot?.callables.length || 0} 个页面函数</span>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新录制" title="刷新录制" disabled={!tab} onClick={() => void load()}><RefreshCw size={15} /></Button>
|
||||
<Button size="icon" variant="ghost" aria-label="清空录制" title="清空录制" disabled={!hasRecording || busy} onClick={() => void clear()}><Trash2 size={15} /></Button>
|
||||
</div>
|
||||
|
||||
{active && snapshot?.status.navigation && (!documentAvailable || ['restored', 'failed'].includes(snapshot.status.navigation.phase))
|
||||
? <div className={`recording-navigation is-${snapshot.status.navigation.phase}`} role="status">
|
||||
<Navigation size={17} />
|
||||
<div>
|
||||
<strong>{snapshot.status.navigation.phase === 'restored'
|
||||
? '已恢复原页面现场'
|
||||
: snapshot.status.navigation.phase === 'failed'
|
||||
? '页面跳转失败,录制仍然保留'
|
||||
: '录制仍在继续,正在连接新页面'}</strong>
|
||||
<span>{snapshot.status.navigation.phase === 'restored'
|
||||
? '浏览器恢复了原文档,页面函数与录制 Hook 已重新可用。'
|
||||
: snapshot.status.navigation.phase === 'failed'
|
||||
? '失败边界已经写入 Trace;如果旧页面仍在,观察器会自动恢复。'
|
||||
: '本次跳转已经写入业务 Trace,新文档可用后会自动接续观察器。'}</span>
|
||||
<code>{snapshot.status.navigation.toUrl || tab?.url}</code>
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{loadError ? <div className="recording-error"><AlertTriangle size={15} />{loadError}<Button size="sm" variant="ghost" onClick={() => void load()}>重试</Button></div>
|
||||
: !hasRecording ? <div className="recording-empty"><Activity size={23} /><strong>录制一次真实页面操作</strong><span>提交登录、查询或业务表单后,这里会按 Trace 还原页面输入、加解密调用与网络请求。</span></div>
|
||||
: <div className="recording-workbench">
|
||||
<aside className="recording-traces">
|
||||
<header><div><strong>录制时间线</strong><small>最早 ↓ 最新</small></div><span>{snapshot?.traces.length || 0}</span></header>
|
||||
<div>
|
||||
{snapshot?.traces.map((trace, index) => <button key={trace.id} className={trace.id === selectedTraceId ? 'is-selected' : ''} onClick={() => setSelectedTraceId(trace.id)}>
|
||||
<span className="recording-trace-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<span><strong>{trace.label}</strong><small>{trace.requestCount} 请求 · {trace.cryptoCount} 加密{trace.messageCount ? ` · ${trace.messageCount} 消息` : ''}{trace.navigationCount ? ` · ${trace.navigationCount} 跳转` : ''}</small></span>
|
||||
<time><span>{new Date(trace.startedAt).toLocaleTimeString()}</span><i>{durationLabel(trace.startedAt, trace.endedAt)}</i></time>
|
||||
</button>)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="recording-pipeline">
|
||||
<header><div><strong>业务执行链</strong><span>{selectedTrace ? `从上到下 · ${selectedTrace.eventIds.length} 个步骤` : '未选择 Trace'}</span></div>{selectedTrace?.linkedValueCount ? <i><Link2 size={12} />{selectedTrace.linkedValueCount} 条精确值关联</i> : null}</header>
|
||||
<div className="recording-pipeline__body">
|
||||
{!traceEvents.length ? <div className="recording-column-empty">当前 Trace 没有事件</div> : traceEvents.map((event, index) => {
|
||||
const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id);
|
||||
const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable);
|
||||
return <div className={`recording-pipeline-step ${event.kind === 'navigation' ? 'is-navigation' : ''}`} key={event.id}>
|
||||
<span className="recording-step-rail" aria-hidden="true"><i>{String(index + 1).padStart(2, '0')}</i>{index < traceEvents.length - 1 ? <span><ArrowDown size={11} /></span> : null}</span>
|
||||
<button data-event-id={event.id} className={`${event.id === selectedEventId ? 'is-selected' : ''} ${linked ? 'is-linked' : ''}`} onClick={() => setSelectedEventId(event.id)}>
|
||||
<span className={`recording-event-icon kind-${event.kind}`}>{eventIcon(event.kind)}</span>
|
||||
<span><small>{KIND_LABELS[event.kind]}</small><strong>{eventTitle(event)}</strong><em>{eventSubtitle(event)}</em>{event.kind === 'navigation' ? <b>{navigationPhaseLabel(event)}</b> : null}</span>
|
||||
<span className="recording-event-meta">{event.callableCapable ? <i className={callableAvailable ? '' : 'is-history'}>{callableAvailable ? '当前可用' : '历史现场'}</i> : null}<time title={new Date(event.timestamp).toLocaleString()}>{relativeTime(event.timestamp, selectedTrace?.startedAt)}</time>{event.durationMs !== undefined ? <small>{event.durationMs.toFixed(1)} ms</small> : null}</span>
|
||||
</button>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="recording-inspector">
|
||||
{!selectedEvent ? <div className="recording-column-empty">选择一个 Pipeline 步骤</div> : <>
|
||||
<header><div><span>{KIND_LABELS[selectedEvent.kind]}</span><strong>{eventTitle(selectedEvent)}</strong><small title={selectedEvent.url || selectedEvent.scriptUrl}>{selectedEvent.url || selectedEvent.scriptUrl || '页面主世界'}</small></div>{selectedEvent.error ? <i className="is-error">ERROR</i> : <i>#{selectedEvent.sequence}</i>}</header>
|
||||
{selectedEvent.kind === 'navigation' && selectedEvent.navigation
|
||||
? <dl className="recording-navigation-detail">
|
||||
<div><dt>状态</dt><dd>{navigationPhaseLabel(selectedEvent)}</dd></div>
|
||||
<div><dt>类型</dt><dd>{selectedEvent.navigation.sameDocument ? '同文档路由' : selectedEvent.navigation.kind === 'back-forward' ? '历史前进/后退' : selectedEvent.navigation.kind === 'reload' ? '重新加载' : '主文档切换'}</dd></div>
|
||||
<div><dt>来源</dt><dd title={selectedEvent.navigation.fromUrl}>{requestPath(selectedEvent.navigation.fromUrl) || '未知页面'}</dd></div>
|
||||
<div><dt>目标</dt><dd title={selectedEvent.navigation.toUrl}>{requestPath(selectedEvent.navigation.toUrl) || '/'}</dd></div>
|
||||
</dl>
|
||||
: <dl><div><dt>输入</dt><dd>{selectedEvent.byteLength === undefined ? `${selectedEvent.inputs.length} 个值` : `${selectedEvent.byteLength} B`}</dd></div><div><dt>输出</dt><dd>{selectedEvent.resultByteLength === undefined ? `${selectedEvent.outputs.length} 个值` : `${selectedEvent.resultByteLength} B`}</dd></div><div><dt>上游</dt><dd>{incomingLinks.length}</dd></div><div><dt>下游</dt><dd>{outgoingLinks.length}</dd></div></dl>}
|
||||
|
||||
{selectedCandidate && <section className={`profile-inference is-${selectedCandidate.confidence.level}`}>
|
||||
<div className="profile-inference__heading">
|
||||
<span className="profile-inference__mark"><Sparkles size={15} /></span>
|
||||
<span><small>自动推断 Profile</small><strong>{selectedCandidate.summary}</strong></span>
|
||||
<i><ShieldCheck size={12} />{confidenceLabel(selectedCandidate)}</i>
|
||||
</div>
|
||||
<div className="profile-inference__flow" aria-label="推断的数据流">
|
||||
{selectedCandidate.flow.map((item, index) => <span key={`${item}-${index}`}>
|
||||
<code>{item}</code>{index < selectedCandidate.flow.length - 1 ? <ChevronRight size={12} /> : null}
|
||||
</span>)}
|
||||
</div>
|
||||
{selectedCandidate.sources.length > 1 && <div className="profile-inference__sources">
|
||||
{selectedCandidate.sources.map((source, index) => <div key={source.eventId}>
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>{source.crypto ? `${cryptoAdapterLabel(source.crypto.adapterId)} ${source.crypto.algorithm || source.operation}` : source.operation}</strong>
|
||||
<small>{source.destination || '输出字段待确认'}</small>
|
||||
</div>)}
|
||||
</div>}
|
||||
{selectedCandidate.sources.length === 1 && selectedCandidate.source.arguments.length > 0 && <dl className="profile-inference__arguments">
|
||||
{selectedCandidate.source.arguments.slice(0, 5).map((argument) => <div key={argument.index}>
|
||||
<dt>{ARGUMENT_LABELS[argument.role]} · arg {argument.index}</dt>
|
||||
<dd>{argument.summary || `${argument.dataType}${argument.byteLength === undefined ? '' : ` · ${argument.byteLength} B`}`}</dd>
|
||||
</div>)}
|
||||
</dl>}
|
||||
<details className="profile-inference__evidence">
|
||||
<summary>{selectedCandidate.evidence.length} 项证据</summary>
|
||||
<ol>{selectedCandidate.evidence.map((item) => <li key={item.id} data-strength={item.strength}><i />{item.label}</li>)}</ol>
|
||||
</details>
|
||||
{selectedCandidate.missing[0] && <div className="profile-inference__next"><span>{selectedCandidate.missing[0].label}</span>
|
||||
{selectedCandidate.missing[0].action === 'capture-business-function' && CHROMIUM_CONTEXT_TOOLS
|
||||
? <Button variant="primary" onClick={() => continueInference(selectedCandidate)}><Sparkles size={14} />自动捕获完整加密流程</Button>
|
||||
: null}
|
||||
</div>}
|
||||
{selectedCandidate.status === 'ready' && <div className="profile-inference__next is-ready"><span>{candidateAvailable ? '页面调用与线上字段已经精确关联,只需确认明文来源和输出形态。' : '关联证据仍然保留;该页面函数属于另一个文档,返回对应页面现场后可以继续生成。'}</span><Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => void createSuggestedGateway(selectedCandidate)}><FileKey2 size={14} />{candidateAvailable ? '生成明文网关' : '等待对应页面'}</Button></div>}
|
||||
</section>}
|
||||
|
||||
{(selectedEvent.inputPreview || selectedEvent.outputPreview) && <div className="recording-values"><strong>短时样本</strong>{selectedEvent.inputPreview && <pre>{selectedEvent.inputPreview}</pre>}{selectedEvent.outputPreview && <pre>{selectedEvent.outputPreview}</pre>}</div>}
|
||||
{selectedEvent.kind !== 'navigation' ? <details className="recording-evidence"><summary>调用证据</summary><pre>{selectedEvent.stack || selectedEvent.scriptUrl || '没有可用调用栈'}</pre></details> : null}
|
||||
|
||||
{canDeepCapture && !selectedCandidate && <section className="recording-deep-action">
|
||||
<div><Bug size={15} /><span><strong>捕获真实业务上下文</strong><small>{selectedEvent.kind === 'crypto'
|
||||
? '下次命中当前加密调用时暂停'
|
||||
: selectedEvent.kind === 'worker' || selectedEvent.kind === 'message' || selectedEvent.kind === 'beacon'
|
||||
? '下次命中当前页面通信边界时暂停'
|
||||
: '下次发出当前请求时暂停'}</small></span></div>
|
||||
<Button variant="primary" onClick={() => setWorkspaceMode('deep')}><Bug size={14} />深入当前调用</Button>
|
||||
</section>}
|
||||
|
||||
{selectedEvent.callableCapable && selectedEvent.callHandleId && <section className="recording-recipe-action">
|
||||
<div><KeyRound size={15} /><span><strong>保存为页面函数</strong><small>{!selectedEventAvailable ? '该调用属于另一个页面文档,返回对应页面后可以恢复' : active ? '保存前会先停止录制,避免轮询继续改变调用现场' : '保留原函数、receiver 与固定参数,页面刷新后失效'}</small></span></div>
|
||||
{!callableEditorOpen ? <Button variant="primary" disabled={busy || !selectedEventAvailable} onClick={prepareCallableEditor}><Save size={14} />{active ? '停止录制并保存' : '保存页面函数'}</Button> : <div className="recording-recipe-editor">
|
||||
<label><span>名称</span><input value={callableName} onChange={(event) => setCallableName(event.target.value)} /></label>
|
||||
<div className="recording-recipe-editor__actions"><Button variant="ghost" onClick={() => setCallableEditorOpen(false)}>取消</Button><Button variant="primary" disabled={!callableName.trim() || busy} onClick={() => void createCallable()}><Check size={14} />创建</Button></div>
|
||||
</div>}
|
||||
</section>}
|
||||
|
||||
{snapshot?.callables.length ? <section className="recording-recipes">
|
||||
<div className="recording-recipes__heading"><strong>验证页面函数</strong><select value={selectedCallableId} onChange={(event) => { setSelectedCallableId(event.target.value); setCallableResult(undefined); }}>{snapshot.callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></div>
|
||||
{selectedCallable && <><div className="recording-recipe-meta"><span>{selectedCallable.operation}</span><i>{selectedCallable.kind === 'recorded-call' ? '录制调用' : selectedCallable.kind === 'request-transaction' ? '请求事务' : '业务闭包'} · {selectedCallable.inputSlots.length} 个参数</i></div><textarea rows={4} value={callableArguments} onChange={(event) => setCallableArguments(event.target.value)} placeholder={'["明文或结构化参数"]'} /><div className="recording-recipe-buttons"><Button variant="ghost" size="icon" aria-label="删除页面函数" title="删除页面函数" onClick={() => void deleteCallable()}><Trash2 size={14} /></Button><Button variant="primary" disabled={busy} onClick={() => void executeCallable()}><Play size={14} />运行验证</Button></div></>}
|
||||
{callableResult && <div className="recording-recipe-result"><div><strong>输出 · {callableResult.type}</strong><span>{callableResult.byteLength === undefined ? '' : `${callableResult.byteLength} B · `}{callableResult.durationMs.toFixed(1)} ms</span><Button size="icon" variant="ghost" aria-label="复制函数输出" title="复制函数输出" onClick={() => void navigator.clipboard.writeText(callableResult.preview)}><Copy size={14} /></Button></div><pre>{callableResult.preview}</pre></div>}
|
||||
</section> : null}
|
||||
</>}
|
||||
</aside>
|
||||
</div>}
|
||||
</div>
|
||||
{CHROMIUM_CONTEXT_TOOLS && <div id="deep-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="deep-mode-tab" hidden={workspaceMode !== 'deep'}>
|
||||
<DeepCaptureWorkspace
|
||||
tab={tab}
|
||||
selectedEvent={selectedEvent}
|
||||
selectedCandidate={selectedCandidate}
|
||||
autoArmRequest={autoArmRequest}
|
||||
busy={busy}
|
||||
run={run}
|
||||
onPausedChange={setDeepPaused}
|
||||
onUseRecommendedCallable={openSuggestedGateway}
|
||||
/>
|
||||
</div>}
|
||||
{CHROMIUM_CONTEXT_TOOLS && <div id="gateway-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="gateway-mode-tab" hidden={workspaceMode !== 'gateway'}>
|
||||
<BrowserTransformWorkspace tab={tab} selectedEvent={selectedEvent} busy={busy} run={run} onOpenCapture={() => setWorkspaceMode('deep')} suggestion={gatewaySuggestion} />
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PAGE_RECORDER_PROTOCOL_VERSION = 9 as const;
|
||||
export const PAGE_RECORDER_REGISTRY_KEY = '__YAKIT_PAGE_RECORDER_V9__' as const;
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserRecordingValueEvidence } from '@/types/models';
|
||||
import {
|
||||
createCommunicationBoundaryRuntime,
|
||||
type CommunicationBoundaryEvent,
|
||||
} from './communication';
|
||||
|
||||
class FakeNavigator {
|
||||
beacons: Array<{ url: string; data: unknown }> = [];
|
||||
|
||||
sendBeacon(url: string, data?: unknown): boolean {
|
||||
this.beacons.push({ url, data });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWorker extends EventTarget {
|
||||
sent: unknown[] = [];
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
postMessage(value: unknown): void {
|
||||
this.sent.push(value);
|
||||
}
|
||||
|
||||
reply(value: unknown): void {
|
||||
const event = new Event('message');
|
||||
Object.defineProperties(event, {
|
||||
data: { value },
|
||||
ports: { value: [] },
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMessagePort extends EventTarget {
|
||||
sent: unknown[] = [];
|
||||
|
||||
postMessage(value: unknown): void {
|
||||
this.sent.push(value);
|
||||
}
|
||||
|
||||
reply(value: unknown): void {
|
||||
const event = new Event('message');
|
||||
Object.defineProperties(event, {
|
||||
data: { value },
|
||||
ports: { value: [] },
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMessageChannel {
|
||||
port1 = new FakeMessagePort();
|
||||
port2 = new FakeMessagePort();
|
||||
}
|
||||
|
||||
function fakeWindow(): Window & {
|
||||
navigator: FakeNavigator;
|
||||
Worker: typeof FakeWorker;
|
||||
MessagePort: typeof FakeMessagePort;
|
||||
MessageChannel: typeof FakeMessageChannel;
|
||||
} {
|
||||
const target = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
target.navigator = new FakeNavigator();
|
||||
target.Worker = FakeWorker;
|
||||
target.MessagePort = FakeMessagePort;
|
||||
target.MessageChannel = FakeMessageChannel;
|
||||
return target as unknown as ReturnType<typeof fakeWindow>;
|
||||
}
|
||||
|
||||
function evidence(value: unknown, path: string): BrowserRecordingValueEvidence[] {
|
||||
return [{ path, fingerprint: `value:${JSON.stringify(value)}`, encoding: 'json', byteLength: 1 }];
|
||||
}
|
||||
|
||||
describe('communication boundary runtime', () => {
|
||||
it('preserves page APIs while correlating Worker send and receive in the originating trace', () => {
|
||||
const scope = fakeWindow();
|
||||
const originalWorker = scope.Worker;
|
||||
const originalPostMessage = FakeWorker.prototype.postMessage;
|
||||
const emitted: Array<{ event: CommunicationBoundaryEvent; context?: { traceId: string } }> = [];
|
||||
let sequence = 0;
|
||||
const runtime = createCommunicationBoundaryRuntime(scope, {
|
||||
unique: (prefix) => `${prefix}-${++sequence}`,
|
||||
describe: (value, path) => ({ dataType: typeof value, byteLength: 1, evidence: evidence(value, path) }),
|
||||
stackInfo: () => ({ scriptUrl: 'https://example.test/app.js' }),
|
||||
emit(event, context) {
|
||||
const resolved = context || { traceId: `trace-${sequence}` };
|
||||
emitted.push({ event, context: resolved });
|
||||
return { ...resolved, scriptUrl: event.scriptUrl };
|
||||
},
|
||||
afterWrapperInvoke: () => undefined,
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
const worker = new scope.Worker('/worker.js');
|
||||
worker.postMessage({ plain: true });
|
||||
worker.reply({ cipher: true });
|
||||
|
||||
const send = emitted.find((item) => item.event.operation === 'worker.postMessage');
|
||||
const receive = emitted.find((item) => item.event.operation === 'worker.message');
|
||||
expect(worker.sent).toEqual([{ plain: true }]);
|
||||
expect(send?.event).toMatchObject({ kind: 'worker', direction: 'send', wrapperHandleId: expect.any(String) });
|
||||
expect(receive?.event).toMatchObject({ kind: 'worker', direction: 'receive', channelId: send?.event.channelId });
|
||||
expect(receive?.context?.traceId).toBe(send?.context?.traceId);
|
||||
expect(runtime.wrapperFunction(send?.event.wrapperHandleId || '')).toBe(FakeWorker.prototype.postMessage);
|
||||
|
||||
runtime.stop();
|
||||
expect(scope.Worker).toBe(originalWorker);
|
||||
expect(FakeWorker.prototype.postMessage).toBe(originalPostMessage);
|
||||
expect(runtime.wrapperFunction(send?.event.wrapperHandleId || '')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records Beacon and MessagePort boundaries without changing return values', () => {
|
||||
const scope = fakeWindow();
|
||||
const emitted: CommunicationBoundaryEvent[] = [];
|
||||
let sequence = 0;
|
||||
const runtime = createCommunicationBoundaryRuntime(scope, {
|
||||
unique: (prefix) => `${prefix}-${++sequence}`,
|
||||
describe: (value, path) => ({ dataType: typeof value, byteLength: 1, evidence: evidence(value, path) }),
|
||||
stackInfo: () => ({}),
|
||||
emit(event, context) {
|
||||
emitted.push(event);
|
||||
return { traceId: context?.traceId || 'trace-1' };
|
||||
},
|
||||
afterWrapperInvoke: () => undefined,
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
expect(scope.navigator.sendBeacon('/audit', 'payload')).toBe(true);
|
||||
const channel = new scope.MessageChannel();
|
||||
channel.port1.postMessage('plain');
|
||||
channel.port1.reply('cipher');
|
||||
|
||||
expect(scope.navigator.beacons).toEqual([{ url: '/audit', data: 'payload' }]);
|
||||
expect(emitted.map((item) => item.operation)).toEqual(expect.arrayContaining([
|
||||
'request', 'message-port.postMessage', 'message-port.message',
|
||||
]));
|
||||
expect(emitted.find((item) => item.operation === 'request')).toMatchObject({
|
||||
kind: 'beacon', method: 'POST', wrapperHandleId: expect.any(String),
|
||||
});
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import type { BrowserRecordingEventKind, BrowserRecordingValueEvidence } from '@/types/models';
|
||||
|
||||
type CommunicationKind = Extract<BrowserRecordingEventKind, 'beacon' | 'worker' | 'message'>;
|
||||
|
||||
export interface CommunicationBoundaryEvent {
|
||||
kind: CommunicationKind;
|
||||
operation: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
direction?: 'send' | 'receive';
|
||||
channelId?: string;
|
||||
wrapperHandleId?: string;
|
||||
byteLength?: number;
|
||||
dataType?: string;
|
||||
inputPreview?: string;
|
||||
outputPreview?: string;
|
||||
inputs?: BrowserRecordingValueEvidence[];
|
||||
outputs?: BrowserRecordingValueEvidence[];
|
||||
stack?: string;
|
||||
scriptUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CommunicationBoundaryHost {
|
||||
unique(prefix: string): string;
|
||||
describe(value: unknown, path: string): {
|
||||
byteLength?: number;
|
||||
dataType: string;
|
||||
preview?: string;
|
||||
evidence: BrowserRecordingValueEvidence[];
|
||||
};
|
||||
stackInfo(): { stack?: string; scriptUrl?: string };
|
||||
emit(
|
||||
input: CommunicationBoundaryEvent,
|
||||
context?: { traceId: string; interactionId?: string },
|
||||
): { scriptUrl?: string; traceId: string; interactionId?: string } | undefined;
|
||||
afterWrapperInvoke(wrapperHandleId: string, scriptUrl?: string): void;
|
||||
}
|
||||
|
||||
export interface CommunicationBoundaryRuntime {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
wrapperFunction(wrapperHandleId: string): Function | undefined;
|
||||
}
|
||||
|
||||
interface KnownWorker {
|
||||
worker: Worker;
|
||||
channelId: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface KnownPort {
|
||||
port: MessagePort;
|
||||
channelId: string;
|
||||
operationPrefix: 'message-port' | 'shared-worker';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const MAX_KNOWN_CHANNELS = 64;
|
||||
|
||||
function ownCallable(owner: Record<string, unknown>, key: string): { descriptor?: PropertyDescriptor; value: Function } | undefined {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
||||
if (descriptor && (!('value' in descriptor) || (!descriptor.writable && !descriptor.configurable))) return undefined;
|
||||
const value = descriptor && 'value' in descriptor ? descriptor.value : owner[key];
|
||||
return typeof value === 'function' ? { descriptor, value } : undefined;
|
||||
}
|
||||
|
||||
export function createCommunicationBoundaryRuntime(
|
||||
scope: Window,
|
||||
host: CommunicationBoundaryHost,
|
||||
): CommunicationBoundaryRuntime {
|
||||
const handleByTarget = new Map<string, string>();
|
||||
const wrapperByHandle = new Map<string, Function>();
|
||||
const restorers: Array<() => void> = [];
|
||||
const workerChannel = new WeakMap<Worker, string>();
|
||||
const workerUrl = new WeakMap<Worker, string>();
|
||||
const portChannel = new WeakMap<MessagePort, string>();
|
||||
const knownWorkers: KnownWorker[] = [];
|
||||
const knownPorts: KnownPort[] = [];
|
||||
const traceByChannel = new Map<string, { traceId: string; interactionId?: string }>();
|
||||
const workerListenerCleanup = new Map<Worker, () => void>();
|
||||
const portListenerCleanup = new Map<MessagePort, () => void>();
|
||||
let active = false;
|
||||
|
||||
const handle = (target: string): string => {
|
||||
const current = handleByTarget.get(target);
|
||||
if (current) return current;
|
||||
const created = host.unique('boundary-wrapper');
|
||||
handleByTarget.set(target, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const rememberWorker = (worker: Worker, url?: string): KnownWorker => {
|
||||
let channelId = workerChannel.get(worker);
|
||||
if (!channelId) {
|
||||
channelId = host.unique('worker-channel');
|
||||
workerChannel.set(worker, channelId);
|
||||
}
|
||||
if (url) workerUrl.set(worker, url);
|
||||
let known = knownWorkers.find((item) => item.worker === worker);
|
||||
if (!known) {
|
||||
known = { worker, channelId, url };
|
||||
knownWorkers.push(known);
|
||||
if (knownWorkers.length > MAX_KNOWN_CHANNELS) {
|
||||
const removed = knownWorkers.shift();
|
||||
if (removed) {
|
||||
workerListenerCleanup.get(removed.worker)?.();
|
||||
workerListenerCleanup.delete(removed.worker);
|
||||
workerChannel.delete(removed.worker);
|
||||
workerUrl.delete(removed.worker);
|
||||
if (!knownWorkers.some((item) => item.channelId === removed.channelId)
|
||||
&& !knownPorts.some((item) => item.channelId === removed.channelId)) {
|
||||
traceByChannel.delete(removed.channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (url) known.url = url;
|
||||
return known;
|
||||
};
|
||||
|
||||
const rememberPort = (
|
||||
port: MessagePort,
|
||||
operationPrefix: KnownPort['operationPrefix'] = 'message-port',
|
||||
url?: string,
|
||||
forcedChannelId?: string,
|
||||
): KnownPort => {
|
||||
let channelId = portChannel.get(port);
|
||||
if (!channelId) {
|
||||
channelId = forcedChannelId || host.unique('message-channel');
|
||||
portChannel.set(port, channelId);
|
||||
}
|
||||
let known = knownPorts.find((item) => item.port === port);
|
||||
if (!known) {
|
||||
known = { port, channelId, operationPrefix, url };
|
||||
knownPorts.push(known);
|
||||
if (knownPorts.length > MAX_KNOWN_CHANNELS) {
|
||||
const removed = knownPorts.shift();
|
||||
if (removed) {
|
||||
portListenerCleanup.get(removed.port)?.();
|
||||
portListenerCleanup.delete(removed.port);
|
||||
portChannel.delete(removed.port);
|
||||
if (!knownPorts.some((item) => item.channelId === removed.channelId)
|
||||
&& !knownWorkers.some((item) => item.channelId === removed.channelId)) {
|
||||
traceByChannel.delete(removed.channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
known.operationPrefix = operationPrefix;
|
||||
if (url) known.url = url;
|
||||
}
|
||||
return known;
|
||||
};
|
||||
|
||||
const observeWorker = (known: KnownWorker): void => {
|
||||
if (workerListenerCleanup.has(known.worker)) return;
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const value = host.describe(event.data, '$message');
|
||||
host.emit({
|
||||
kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: known.channelId,
|
||||
url: known.url, byteLength: value.byteLength, dataType: value.dataType,
|
||||
outputPreview: value.preview, outputs: value.evidence,
|
||||
}, traceByChannel.get(known.channelId));
|
||||
for (const port of event.ports || []) observePort(rememberPort(port));
|
||||
};
|
||||
const onError = (event: ErrorEvent) => host.emit({
|
||||
kind: 'worker', operation: 'worker.error', direction: 'receive', channelId: known.channelId,
|
||||
url: known.url, error: String(event.message || 'Worker error').slice(0, 512),
|
||||
});
|
||||
known.worker.addEventListener('message', onMessage);
|
||||
known.worker.addEventListener('error', onError);
|
||||
workerListenerCleanup.set(known.worker, () => {
|
||||
known.worker.removeEventListener('message', onMessage);
|
||||
known.worker.removeEventListener('error', onError);
|
||||
});
|
||||
};
|
||||
|
||||
const observePort = (known: KnownPort): void => {
|
||||
if (portListenerCleanup.has(known.port)) return;
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const value = host.describe(event.data, '$message');
|
||||
host.emit({
|
||||
kind: 'message', operation: `${known.operationPrefix}.message`, direction: 'receive',
|
||||
channelId: known.channelId, url: known.url, byteLength: value.byteLength,
|
||||
dataType: value.dataType, outputPreview: value.preview, outputs: value.evidence,
|
||||
}, traceByChannel.get(known.channelId));
|
||||
for (const port of event.ports || []) observePort(rememberPort(port));
|
||||
};
|
||||
const onMessageError = () => host.emit({
|
||||
kind: 'message', operation: `${known.operationPrefix}.message-error`, direction: 'receive',
|
||||
channelId: known.channelId, url: known.url, error: 'Message could not be deserialized',
|
||||
});
|
||||
known.port.addEventListener('message', onMessage);
|
||||
known.port.addEventListener('messageerror', onMessageError);
|
||||
portListenerCleanup.set(known.port, () => {
|
||||
known.port.removeEventListener('message', onMessage);
|
||||
known.port.removeEventListener('messageerror', onMessageError);
|
||||
});
|
||||
};
|
||||
|
||||
const transferredPorts = (value: unknown): MessagePort[] => {
|
||||
const Port = (scope as unknown as { MessagePort?: typeof MessagePort }).MessagePort;
|
||||
if (typeof Port !== 'function') return [];
|
||||
let items: unknown[] = [];
|
||||
if (Array.isArray(value)) items = value;
|
||||
else if (value && typeof value === 'object' && Array.isArray((value as { transfer?: unknown }).transfer)) {
|
||||
items = (value as { transfer: unknown[] }).transfer;
|
||||
}
|
||||
return items.filter((item): item is MessagePort => item instanceof Port);
|
||||
};
|
||||
|
||||
const installValue = (
|
||||
owner: Record<string, unknown>,
|
||||
key: string,
|
||||
target: string,
|
||||
create: (original: Function, wrapperHandleId: string) => Function,
|
||||
): void => {
|
||||
const callable = ownCallable(owner, key);
|
||||
if (!callable) return;
|
||||
const wrapperHandleId = handle(target);
|
||||
const wrapped = create(callable.value, wrapperHandleId);
|
||||
try {
|
||||
if (callable.descriptor) Object.defineProperty(owner, key, { ...callable.descriptor, value: wrapped });
|
||||
else owner[key] = wrapped;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
wrapperByHandle.set(wrapperHandleId, wrapped);
|
||||
restorers.push(() => {
|
||||
if (owner[key] === wrapped) {
|
||||
try {
|
||||
if (callable.descriptor) Object.defineProperty(owner, key, callable.descriptor);
|
||||
else delete owner[key];
|
||||
} catch {
|
||||
// The page owns a later replacement.
|
||||
}
|
||||
}
|
||||
if (wrapperByHandle.get(wrapperHandleId) === wrapped) wrapperByHandle.delete(wrapperHandleId);
|
||||
});
|
||||
};
|
||||
|
||||
const installSendBeacon = (): void => {
|
||||
const navigatorPrototype = Object.getPrototypeOf(scope.navigator) as Record<string, unknown> | null;
|
||||
if (!navigatorPrototype) return;
|
||||
installValue(navigatorPrototype, 'sendBeacon', 'navigator.sendBeacon', (original, wrapperHandleId) => (
|
||||
function recordedSendBeacon(this: Navigator, url: string | URL, data?: BodyInit | null): boolean {
|
||||
const source = host.stackInfo();
|
||||
const value = host.describe(data, '$body');
|
||||
const item = host.emit({
|
||||
kind: 'beacon', operation: 'request', method: 'POST', url: String(url).slice(0, 8_192),
|
||||
wrapperHandleId, byteLength: value.byteLength, dataType: value.dataType,
|
||||
inputPreview: value.preview, inputs: value.evidence, ...source,
|
||||
});
|
||||
host.afterWrapperInvoke(wrapperHandleId, item?.scriptUrl);
|
||||
return Reflect.apply(original, this, [url, data]);
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
const installWorker = (): void => {
|
||||
const WorkerConstructor = (scope as unknown as Record<string, unknown>).Worker;
|
||||
if (typeof WorkerConstructor !== 'function') return;
|
||||
const prototype = (WorkerConstructor as { prototype?: Record<string, unknown> }).prototype;
|
||||
if (prototype) installValue(prototype, 'postMessage', 'worker.postMessage', (original, wrapperHandleId) => (
|
||||
function recordedWorkerPostMessage(this: Worker, message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
|
||||
const known = rememberWorker(this, workerUrl.get(this));
|
||||
observeWorker(known);
|
||||
for (const port of transferredPorts(transferOrOptions)) observePort(rememberPort(port));
|
||||
const source = host.stackInfo();
|
||||
const value = host.describe(message, '$message');
|
||||
const item = host.emit({
|
||||
kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: known.channelId,
|
||||
url: known.url, wrapperHandleId, byteLength: value.byteLength, dataType: value.dataType,
|
||||
inputPreview: value.preview, inputs: value.evidence, ...source,
|
||||
});
|
||||
if (item) traceByChannel.set(known.channelId, { traceId: item.traceId, interactionId: item.interactionId });
|
||||
host.afterWrapperInvoke(wrapperHandleId, item?.scriptUrl);
|
||||
return Reflect.apply(original, this, transferOrOptions === undefined ? [message] : [message, transferOrOptions]);
|
||||
}
|
||||
));
|
||||
installValue(scope as unknown as Record<string, unknown>, 'Worker', 'worker.constructor', (original) => new Proxy(original, {
|
||||
construct(target, args, newTarget) {
|
||||
const worker = Reflect.construct(target, args, newTarget) as Worker;
|
||||
const url = String(args[0] || '').slice(0, 8_192);
|
||||
const known = rememberWorker(worker, url);
|
||||
observeWorker(known);
|
||||
host.emit({ kind: 'worker', operation: 'worker.construct', channelId: known.channelId, url, ...host.stackInfo() });
|
||||
return worker;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const installMessagePorts = (): void => {
|
||||
const Port = (scope as unknown as { MessagePort?: typeof MessagePort }).MessagePort;
|
||||
if (typeof Port === 'function') {
|
||||
installValue(Port.prototype as unknown as Record<string, unknown>, 'postMessage', 'message-port.postMessage', (original, wrapperHandleId) => (
|
||||
function recordedMessagePortPostMessage(this: MessagePort, message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
|
||||
const known = rememberPort(this);
|
||||
observePort(known);
|
||||
for (const port of transferredPorts(transferOrOptions)) observePort(rememberPort(port));
|
||||
const source = host.stackInfo();
|
||||
const value = host.describe(message, '$message');
|
||||
const item = host.emit({
|
||||
kind: 'message', operation: `${known.operationPrefix}.postMessage`, direction: 'send',
|
||||
channelId: known.channelId, url: known.url, wrapperHandleId,
|
||||
byteLength: value.byteLength, dataType: value.dataType,
|
||||
inputPreview: value.preview, inputs: value.evidence, ...source,
|
||||
});
|
||||
if (item) traceByChannel.set(known.channelId, { traceId: item.traceId, interactionId: item.interactionId });
|
||||
host.afterWrapperInvoke(wrapperHandleId, item?.scriptUrl);
|
||||
return Reflect.apply(original, this, transferOrOptions === undefined ? [message] : [message, transferOrOptions]);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
const Channel = (scope as unknown as Record<string, unknown>).MessageChannel;
|
||||
if (typeof Channel === 'function') installValue(scope as unknown as Record<string, unknown>, 'MessageChannel', 'message-channel.constructor', (original) => new Proxy(original, {
|
||||
construct(target, args, newTarget) {
|
||||
const channel = Reflect.construct(target, args, newTarget) as MessageChannel;
|
||||
const channelId = host.unique('message-channel');
|
||||
observePort(rememberPort(channel.port1, 'message-port', undefined, channelId));
|
||||
observePort(rememberPort(channel.port2, 'message-port', undefined, channelId));
|
||||
return channel;
|
||||
},
|
||||
}));
|
||||
|
||||
const Shared = (scope as unknown as Record<string, unknown>).SharedWorker;
|
||||
if (typeof Shared === 'function') installValue(scope as unknown as Record<string, unknown>, 'SharedWorker', 'shared-worker.constructor', (original) => new Proxy(original, {
|
||||
construct(target, args, newTarget) {
|
||||
const worker = Reflect.construct(target, args, newTarget) as SharedWorker;
|
||||
const url = String(args[0] || '').slice(0, 8_192);
|
||||
const known = rememberPort(worker.port, 'shared-worker', url);
|
||||
observePort(known);
|
||||
host.emit({ kind: 'message', operation: 'shared-worker.construct', channelId: known.channelId, url, ...host.stackInfo() });
|
||||
return worker;
|
||||
},
|
||||
}));
|
||||
|
||||
const onWindowMessage = (event: MessageEvent) => {
|
||||
for (const port of event.ports || []) observePort(rememberPort(port));
|
||||
};
|
||||
scope.addEventListener('message', onWindowMessage, true);
|
||||
restorers.push(() => scope.removeEventListener('message', onWindowMessage, true));
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
installSendBeacon();
|
||||
installWorker();
|
||||
installMessagePorts();
|
||||
for (const worker of knownWorkers) observeWorker(worker);
|
||||
for (const port of knownPorts) observePort(port);
|
||||
},
|
||||
stop() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
while (restorers.length) {
|
||||
try { restorers.pop()!(); } catch { /* Best-effort observer cleanup. */ }
|
||||
}
|
||||
for (const cleanup of workerListenerCleanup.values()) {
|
||||
try { cleanup(); } catch { /* Best effort. */ }
|
||||
}
|
||||
workerListenerCleanup.clear();
|
||||
for (const cleanup of portListenerCleanup.values()) {
|
||||
try { cleanup(); } catch { /* Best effort. */ }
|
||||
}
|
||||
portListenerCleanup.clear();
|
||||
knownWorkers.length = 0;
|
||||
knownPorts.length = 0;
|
||||
traceByChannel.clear();
|
||||
wrapperByHandle.clear();
|
||||
},
|
||||
wrapperFunction(wrapperHandleId) {
|
||||
return wrapperByHandle.get(wrapperHandleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BrowserRecordingValueEvidence } from '@/types/models';
|
||||
import { createRequestPreparationRuntime, type RequestPreparationHost } from './request-preparation';
|
||||
|
||||
function evidence(value: unknown, path: string): BrowserRecordingValueEvidence[] {
|
||||
if (value === undefined) return [];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof URLSearchParams)) {
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => evidence(item, `${path}.${key}`));
|
||||
}
|
||||
const text = value instanceof URLSearchParams ? value.toString() : String(value);
|
||||
return [{ path, fingerprint: `fp:${text}`, encoding: 'text', byteLength: text.length }];
|
||||
}
|
||||
|
||||
function environment() {
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const listeners = new Set<EventListener>();
|
||||
const document = {
|
||||
addEventListener(type: string, listener: EventListener) { if (type === 'load') listeners.add(listener); },
|
||||
removeEventListener(type: string, listener: EventListener) { if (type === 'load') listeners.delete(listener); },
|
||||
};
|
||||
const window = {
|
||||
JSON: { stringify: JSON.stringify },
|
||||
URLSearchParams,
|
||||
document,
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
} as unknown as Window & {
|
||||
axios?: unknown;
|
||||
JSON: Pick<JSON, 'stringify'>;
|
||||
URLSearchParams: typeof URLSearchParams;
|
||||
};
|
||||
const host: RequestPreparationHost = {
|
||||
currentTrace: () => ({ traceId: 'trace-1', interactionId: 'interaction-1' }),
|
||||
collectEvidence: evidence,
|
||||
byteLength: (value) => value === undefined ? undefined : new TextEncoder().encode(
|
||||
typeof value === 'string' ? value : String(value),
|
||||
).byteLength,
|
||||
dataType: (value) => typeof value,
|
||||
preview: () => undefined,
|
||||
stackInfo: () => ({ scriptUrl: 'https://example.test/app.js' }),
|
||||
emit: (event, context) => events.push({ ...event, ...context }),
|
||||
};
|
||||
return { events, window, host };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('request preparation evidence', () => {
|
||||
it('links JSON and query canonicalization without changing native return values', () => {
|
||||
vi.useFakeTimers();
|
||||
const { events, window, host } = environment();
|
||||
const runtime = createRequestPreparationRuntime(window, host);
|
||||
runtime.start();
|
||||
|
||||
expect(window.JSON.stringify({ b: 2, a: 1 })).toBe('{"b":2,"a":1}');
|
||||
const params = new window.URLSearchParams('z=2&a=1');
|
||||
params.sort();
|
||||
expect(params.toString()).toBe('a=1&z=2');
|
||||
|
||||
expect(events.map((event) => event.operation)).toEqual([
|
||||
'JSON.stringify', 'URLSearchParams.sort', 'URLSearchParams.toString',
|
||||
]);
|
||||
expect(events[0].transform).toEqual({ category: 'serializer', provider: 'native', phase: 'output' });
|
||||
expect(events[1].transform).toEqual({ category: 'canonicalization', provider: 'native', phase: 'output' });
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('discovers Axios with bounded retry and exposes a transparent request-builder edge', () => {
|
||||
vi.useFakeTimers();
|
||||
const { events, window, host } = environment();
|
||||
const runtime = createRequestPreparationRuntime(window, host);
|
||||
runtime.start();
|
||||
|
||||
class Axios {
|
||||
request(config: unknown) { return Promise.resolve(config); }
|
||||
}
|
||||
window.axios = { Axios };
|
||||
vi.advanceTimersByTime(50);
|
||||
const instance = new Axios();
|
||||
const result = instance.request({
|
||||
data: { account: 'admin' },
|
||||
headers: { 'X-Signature': 'signed-value' },
|
||||
params: { nonce: 'nonce-value' },
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
const axios = events.find((event) => event.operation === 'axios.request');
|
||||
expect(axios?.transform).toEqual({ category: 'request-builder', provider: 'axios', phase: 'boundary' });
|
||||
expect(axios?.inputs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: '$headers.X-Signature', fingerprint: 'fp:signed-value' }),
|
||||
expect.objectContaining({ path: '$query.nonce', fingerprint: 'fp:nonce-value' }),
|
||||
]));
|
||||
expect(axios?.outputs).toEqual(axios?.inputs);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('caps noisy serializer evidence per trace', () => {
|
||||
vi.useFakeTimers();
|
||||
const { events, window, host } = environment();
|
||||
const runtime = createRequestPreparationRuntime(window, host);
|
||||
runtime.start();
|
||||
for (let index = 0; index < 50; index += 1) window.JSON.stringify({ index });
|
||||
expect(events.filter((event) => event.operation === 'JSON.stringify')).toHaveLength(32);
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
BrowserRecordingTransform,
|
||||
BrowserRecordingValueEvidence,
|
||||
} from '@/types/models';
|
||||
|
||||
interface TraceContext {
|
||||
traceId: string;
|
||||
interactionId?: string;
|
||||
}
|
||||
|
||||
interface PreparationEvent {
|
||||
operation: string;
|
||||
label: string;
|
||||
transform: BrowserRecordingTransform;
|
||||
inputs: BrowserRecordingValueEvidence[];
|
||||
outputs: BrowserRecordingValueEvidence[];
|
||||
byteLength?: number;
|
||||
resultByteLength?: number;
|
||||
dataType?: string;
|
||||
inputPreview?: string;
|
||||
outputPreview?: string;
|
||||
stack?: string;
|
||||
scriptUrl?: string;
|
||||
}
|
||||
|
||||
export interface RequestPreparationHost {
|
||||
currentTrace(): TraceContext | undefined;
|
||||
collectEvidence(value: unknown, path: string): BrowserRecordingValueEvidence[];
|
||||
byteLength(value: unknown): number | undefined;
|
||||
dataType(value: unknown): string;
|
||||
preview(value: unknown): string | undefined;
|
||||
stackInfo(): { stack?: string; scriptUrl?: string };
|
||||
emit(event: PreparationEvent, context: TraceContext): void;
|
||||
}
|
||||
|
||||
export interface RequestPreparationRuntime {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
ensureAxios(): void;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS = [50, 250, 1_000, 3_000] as const;
|
||||
const MAX_STAGES_PER_TRACE = 32;
|
||||
const MAX_TRACKED_TRACES = 64;
|
||||
const MAX_SERIALIZED_BYTES = 262_144;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && (typeof value === 'object' || typeof value === 'function')
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function method(owner: Record<string, unknown> | undefined, key: string): Function | undefined {
|
||||
try { return typeof owner?.[key] === 'function' ? owner[key] as Function : undefined; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function axiosRequestOwner(window: Window): { owner: Record<string, unknown>; key: string } | undefined {
|
||||
const axios = record((window as unknown as { axios?: unknown }).axios);
|
||||
if (!axios) return undefined;
|
||||
const prototype = record(record(axios.Axios)?.prototype);
|
||||
if (method(prototype, 'request')) return { owner: prototype!, key: 'request' };
|
||||
if (method(axios, 'request')) return { owner: axios, key: 'request' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function configValue(config: Record<string, unknown> | undefined, key: string): unknown {
|
||||
try { return config?.[key]; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function axiosHeaders(value: unknown): unknown {
|
||||
const input = record(value);
|
||||
try {
|
||||
return typeof input?.toJSON === 'function' ? Reflect.apply(input.toJSON as Function, value, []) : value;
|
||||
} catch { return value; }
|
||||
}
|
||||
|
||||
export function createRequestPreparationRuntime(
|
||||
window: Window,
|
||||
host: RequestPreparationHost,
|
||||
): RequestPreparationRuntime {
|
||||
const restorers: Array<() => void> = [];
|
||||
const retryTimers = new Set<number>();
|
||||
const wrappers = new WeakSet<Function>();
|
||||
const stagesByTrace = new Map<string, number>();
|
||||
let active = false;
|
||||
let reentrant = false;
|
||||
|
||||
const admit = (): TraceContext | undefined => {
|
||||
const context = host.currentTrace();
|
||||
if (!context) return undefined;
|
||||
const count = stagesByTrace.get(context.traceId) || 0;
|
||||
if (count >= MAX_STAGES_PER_TRACE) return undefined;
|
||||
stagesByTrace.delete(context.traceId);
|
||||
stagesByTrace.set(context.traceId, count + 1);
|
||||
while (stagesByTrace.size > MAX_TRACKED_TRACES) stagesByTrace.delete(stagesByTrace.keys().next().value!);
|
||||
return context;
|
||||
};
|
||||
|
||||
const emit = (factory: () => PreparationEvent): void => {
|
||||
if (reentrant) return;
|
||||
const context = admit();
|
||||
if (!context) return;
|
||||
reentrant = true;
|
||||
try { host.emit(factory(), context); } catch { /* Transform evidence is best effort. */ } finally { reentrant = false; }
|
||||
};
|
||||
|
||||
const replace = (owner: Record<string, unknown>, key: string, wrapped: Function): boolean => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
||||
if (descriptor && (!('value' in descriptor) || (!descriptor.writable && !descriptor.configurable))) return false;
|
||||
try {
|
||||
if (descriptor) Object.defineProperty(owner, key, { ...descriptor, value: wrapped });
|
||||
else owner[key] = wrapped;
|
||||
} catch { return false; }
|
||||
wrappers.add(wrapped);
|
||||
restorers.push(() => {
|
||||
if (owner[key] !== wrapped) return;
|
||||
try {
|
||||
if (descriptor) Object.defineProperty(owner, key, descriptor);
|
||||
else delete owner[key];
|
||||
} catch { /* A page replacement wins during cleanup. */ }
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const installJson = (): void => {
|
||||
const json = record((window as unknown as { JSON?: unknown }).JSON);
|
||||
const original = method(json, 'stringify');
|
||||
if (!json || !original || wrappers.has(original)) return;
|
||||
const wrapped = function recordedJsonStringify(this: JSON, ...args: unknown[]): unknown {
|
||||
const output = Reflect.apply(original, this, args);
|
||||
const input = args[0];
|
||||
if (input && typeof input === 'object' && typeof output === 'string') {
|
||||
const resultBytes = host.byteLength(output);
|
||||
if (resultBytes !== undefined && resultBytes <= MAX_SERIALIZED_BYTES) emit(() => ({
|
||||
operation: 'JSON.stringify',
|
||||
label: 'JSON 序列化',
|
||||
transform: { category: 'serializer', provider: 'native', phase: 'output' },
|
||||
inputs: host.collectEvidence(input, '$input'),
|
||||
outputs: host.collectEvidence(output, '$output'),
|
||||
byteLength: host.byteLength(input),
|
||||
resultByteLength: resultBytes,
|
||||
dataType: host.dataType(input),
|
||||
inputPreview: host.preview(input),
|
||||
outputPreview: host.preview(output),
|
||||
...host.stackInfo(),
|
||||
}));
|
||||
}
|
||||
return output;
|
||||
};
|
||||
replace(json, 'stringify', wrapped);
|
||||
};
|
||||
|
||||
const installUrlSearchParams = (): void => {
|
||||
const Constructor = (window as unknown as { URLSearchParams?: typeof URLSearchParams }).URLSearchParams;
|
||||
const prototype = Constructor?.prototype as unknown as Record<string, unknown> | undefined;
|
||||
if (!prototype) return;
|
||||
const nativeToString = method(prototype, 'toString');
|
||||
if (nativeToString && !wrappers.has(nativeToString)) {
|
||||
const wrapped = function recordedSearchParamsToString(this: URLSearchParams): string {
|
||||
const output = Reflect.apply(nativeToString, this, []) as string;
|
||||
if (host.byteLength(output)! <= MAX_SERIALIZED_BYTES) emit(() => ({
|
||||
operation: 'URLSearchParams.toString',
|
||||
label: 'Query/Form 序列化',
|
||||
transform: { category: 'serializer', provider: 'native', phase: 'output' },
|
||||
inputs: host.collectEvidence(this, '$input'),
|
||||
outputs: host.collectEvidence(output, '$output'),
|
||||
byteLength: host.byteLength(this),
|
||||
resultByteLength: host.byteLength(output),
|
||||
dataType: 'URLSearchParams',
|
||||
inputPreview: host.preview(this),
|
||||
outputPreview: host.preview(output),
|
||||
...host.stackInfo(),
|
||||
}));
|
||||
return output;
|
||||
};
|
||||
replace(prototype, 'toString', wrapped);
|
||||
}
|
||||
const nativeSort = method(prototype, 'sort');
|
||||
if (nativeSort && nativeToString && !wrappers.has(nativeSort)) {
|
||||
const wrapped = function recordedSearchParamsSort(this: URLSearchParams): void {
|
||||
const before = Reflect.apply(nativeToString, this, []) as string;
|
||||
Reflect.apply(nativeSort, this, []);
|
||||
const after = Reflect.apply(nativeToString, this, []) as string;
|
||||
emit(() => ({
|
||||
operation: 'URLSearchParams.sort',
|
||||
label: 'Query 参数排序',
|
||||
transform: { category: 'canonicalization', provider: 'native', phase: 'output' },
|
||||
inputs: host.collectEvidence(before, '$input'),
|
||||
outputs: host.collectEvidence(after, '$output'),
|
||||
byteLength: host.byteLength(before),
|
||||
resultByteLength: host.byteLength(after),
|
||||
dataType: 'URLSearchParams',
|
||||
inputPreview: host.preview(before),
|
||||
outputPreview: host.preview(after),
|
||||
...host.stackInfo(),
|
||||
}));
|
||||
};
|
||||
replace(prototype, 'sort', wrapped);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAxios = (): void => {
|
||||
if (!active) return;
|
||||
const resolved = axiosRequestOwner(window);
|
||||
if (!resolved) return;
|
||||
const original = method(resolved.owner, resolved.key);
|
||||
if (!original || wrappers.has(original)) return;
|
||||
const wrapped = function recordedAxiosRequest(this: unknown, ...args: unknown[]): unknown {
|
||||
emit(() => {
|
||||
const config = record(typeof args[0] === 'string' ? args[1] : args[0]);
|
||||
const body = configValue(config, 'data');
|
||||
const headers = axiosHeaders(configValue(config, 'headers'));
|
||||
const query = configValue(config, 'params');
|
||||
const evidence = [
|
||||
...host.collectEvidence(body, '$body'),
|
||||
...host.collectEvidence(headers, '$headers'),
|
||||
...host.collectEvidence(query, '$query'),
|
||||
].slice(0, 48);
|
||||
return {
|
||||
operation: 'axios.request',
|
||||
label: 'Axios 请求准备',
|
||||
transform: { category: 'request-builder', provider: 'axios', phase: 'boundary' },
|
||||
inputs: evidence,
|
||||
outputs: evidence.map((item) => ({ ...item })),
|
||||
byteLength: host.byteLength(body),
|
||||
dataType: host.dataType(body),
|
||||
inputPreview: host.preview(body),
|
||||
...host.stackInfo(),
|
||||
};
|
||||
});
|
||||
return Reflect.apply(original, this, args);
|
||||
};
|
||||
replace(resolved.owner, resolved.key, wrapped);
|
||||
};
|
||||
|
||||
const onResourceLoad = (event: Event): void => {
|
||||
const target = event.target as { tagName?: unknown } | null;
|
||||
if (target?.tagName === 'SCRIPT') ensureAxios();
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
installJson();
|
||||
installUrlSearchParams();
|
||||
ensureAxios();
|
||||
window.document.addEventListener('load', onResourceLoad, true);
|
||||
for (const delay of RETRY_DELAYS) {
|
||||
const timer = window.setTimeout(() => {
|
||||
retryTimers.delete(timer);
|
||||
ensureAxios();
|
||||
}, delay);
|
||||
retryTimers.add(timer);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
window.document.removeEventListener('load', onResourceLoad, true);
|
||||
for (const timer of retryTimers) window.clearTimeout(timer);
|
||||
retryTimers.clear();
|
||||
while (restorers.length) {
|
||||
try { restorers.pop()!(); } catch { /* Cleanup remains best effort. */ }
|
||||
}
|
||||
stagesByTrace.clear();
|
||||
},
|
||||
ensureAxios,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RetainedCallBudget } from './retained-call-budget';
|
||||
|
||||
describe('RetainedCallBudget', () => {
|
||||
it('evicts oldest handles by count without exceeding the byte budget', () => {
|
||||
const budget = new RetainedCallBudget<{ id: string; retainedBytes: number; value: string }>({
|
||||
maxCount: 2,
|
||||
maxBytes: 10,
|
||||
maxEntryBytes: 8,
|
||||
});
|
||||
expect(budget.add({ id: 'a', retainedBytes: 4, value: 'a' })).toBe(true);
|
||||
expect(budget.add({ id: 'b', retainedBytes: 4, value: 'b' })).toBe(true);
|
||||
expect(budget.add({ id: 'c', retainedBytes: 4, value: 'c' })).toBe(true);
|
||||
expect(budget.get('a')).toBeUndefined();
|
||||
expect(budget.get('b')?.value).toBe('b');
|
||||
expect(budget.retainedBytes).toBe(8);
|
||||
});
|
||||
|
||||
it('rejects an oversized handle and releases accounting on delete and clear', () => {
|
||||
const budget = new RetainedCallBudget({ maxCount: 4, maxBytes: 8, maxEntryBytes: 5 });
|
||||
expect(budget.add({ id: 'too-large', retainedBytes: 6 })).toBe(false);
|
||||
expect(budget.add({ id: 'a', retainedBytes: 5 })).toBe(true);
|
||||
expect(budget.add({ id: 'b', retainedBytes: 5 })).toBe(true);
|
||||
expect(budget.get('a')).toBeUndefined();
|
||||
expect(budget.retainedBytes).toBe(5);
|
||||
expect(budget.delete('b')).toBe(true);
|
||||
expect(budget.retainedBytes).toBe(0);
|
||||
budget.add({ id: 'c', retainedBytes: 3 });
|
||||
budget.clear();
|
||||
expect(budget.size).toBe(0);
|
||||
expect(budget.retainedBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface RetainedCallBudgetEntry {
|
||||
id: string;
|
||||
retainedBytes: number;
|
||||
}
|
||||
|
||||
export interface RetainedCallBudgetOptions {
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
maxEntryBytes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: RetainedCallBudgetOptions = {
|
||||
maxCount: 64,
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
maxEntryBytes: 2 * 1024 * 1024,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps document-bound replay handles inside both a count and retained-memory
|
||||
* budget. Eviction is FIFO because recent calls are the ones exposed by the
|
||||
* recording UI and are the most likely to be promoted to a saved callable.
|
||||
*/
|
||||
export class RetainedCallBudget<T extends RetainedCallBudgetEntry> {
|
||||
readonly #entries = new Map<string, T>();
|
||||
|
||||
readonly #order: string[] = [];
|
||||
|
||||
readonly #options: RetainedCallBudgetOptions;
|
||||
|
||||
#retainedBytes = 0;
|
||||
|
||||
constructor(options: Partial<RetainedCallBudgetOptions> = {}) {
|
||||
this.#options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
get retainedBytes(): number {
|
||||
return this.#retainedBytes;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
get(id: string): T | undefined {
|
||||
return this.#entries.get(id);
|
||||
}
|
||||
|
||||
add(entry: T): boolean {
|
||||
const weight = Math.max(0, Math.ceil(entry.retainedBytes));
|
||||
if (weight > this.#options.maxEntryBytes || weight > this.#options.maxBytes) return false;
|
||||
this.delete(entry.id);
|
||||
while (this.#order.length >= this.#options.maxCount
|
||||
|| (this.#order.length > 0 && this.#retainedBytes + weight > this.#options.maxBytes)) {
|
||||
const oldest = this.#order[0];
|
||||
if (!oldest) break;
|
||||
this.delete(oldest);
|
||||
}
|
||||
if (this.#retainedBytes + weight > this.#options.maxBytes) return false;
|
||||
this.#entries.set(entry.id, { ...entry, retainedBytes: weight });
|
||||
this.#order.push(entry.id);
|
||||
this.#retainedBytes += weight;
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const current = this.#entries.get(id);
|
||||
if (!current) return false;
|
||||
this.#entries.delete(id);
|
||||
const index = this.#order.indexOf(id);
|
||||
if (index >= 0) this.#order.splice(index, 1);
|
||||
this.#retainedBytes = Math.max(0, this.#retainedBytes - current.retainedBytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
this.#order.length = 0;
|
||||
this.#retainedBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import { scriptingTarget } from '@/platform/browser/targets';
|
||||
import type {
|
||||
BrowserDeepCaptureMatcher, BrowserPageCallable, BrowserRecordingCallArgument, BrowserRecordingEvent, BrowserRecordingNavigation,
|
||||
BrowserRecordingOptions, BrowserRecordingSnapshot, BrowserRecordingStatus,
|
||||
BrowserRecordingValueEvidence, BrowserTarget,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { inferBrowserTransformProfiles } from '@/features/browser-inference/inference';
|
||||
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
||||
import { normalizeCallable } from '@/features/page-callable/service';
|
||||
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from './constants';
|
||||
import {
|
||||
buildRecordingLinks,
|
||||
buildRecordingTraces,
|
||||
latestRecordingTraceId,
|
||||
MAX_RECORDING_EVENTS,
|
||||
mergeRecordingEvents,
|
||||
nextRecordingSequence,
|
||||
} from './timeline';
|
||||
|
||||
const RECORDER_SCRIPT = '/page-recorder-main-world.js' as const;
|
||||
const DEFAULT_OPTIONS: BrowserRecordingOptions = { captureValues: false, maxEntries: 200, maxValueBytes: 2_048 };
|
||||
const MAX_ENTRIES = MAX_RECORDING_EVENTS;
|
||||
|
||||
interface RawRecorderSnapshot {
|
||||
version: typeof PAGE_RECORDER_PROTOCOL_VERSION;
|
||||
active: boolean;
|
||||
recordingId?: string;
|
||||
startedAt?: number;
|
||||
count: number;
|
||||
droppedCount: number;
|
||||
options?: BrowserRecordingOptions;
|
||||
events: BrowserRecordingEvent[];
|
||||
callables: unknown[];
|
||||
}
|
||||
|
||||
interface OwnedRecording {
|
||||
target: BrowserTarget;
|
||||
owner: { kind: 'local' } | { kind: 'grant'; grantId: string };
|
||||
}
|
||||
|
||||
interface StoredRecordingSession {
|
||||
snapshot: BrowserRecordingSnapshot;
|
||||
owner?: OwnedRecording['owner'];
|
||||
}
|
||||
|
||||
type RecorderCommand = 'start' | 'resume' | 'status' | 'get' | 'clear' | 'stop' | 'navigation.record'
|
||||
| 'callable.create' | 'deep.arm' | 'deep.disarm';
|
||||
const ownedRecordings = new Map<string, OwnedRecording>();
|
||||
const latestSnapshots = new Map<string, BrowserRecordingSnapshot>();
|
||||
const sessionOwners = new Map<string, OwnedRecording['owner']>();
|
||||
const lifecycleQueues = new Map<string, Promise<void>>();
|
||||
const removedTabs = new Set<number>();
|
||||
const RECORDING_SESSION_STORAGE_KEY = 'session.browser-recording-sessions.v3';
|
||||
let sessionStorageQueue: Promise<void> = Promise.resolve();
|
||||
let sessionRestorePromise: Promise<void> | undefined;
|
||||
|
||||
function targetKey(target: BrowserTarget): string {
|
||||
return `${target.tabId}:${target.frameId}`;
|
||||
}
|
||||
|
||||
async function readStoredSessions(): Promise<Record<string, StoredRecordingSession>> {
|
||||
try {
|
||||
const stored = await browser.storage.session.get(RECORDING_SESSION_STORAGE_KEY);
|
||||
const value = stored[RECORDING_SESSION_STORAGE_KEY];
|
||||
return value && typeof value === 'object' ? value as Record<string, StoredRecordingSession> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionsRestored(): Promise<void> {
|
||||
sessionRestorePromise ||= readStoredSessions().then((sessions) => {
|
||||
for (const [key, stored] of Object.entries(sessions)) {
|
||||
if (!stored?.snapshot?.status?.startedAt) continue;
|
||||
latestSnapshots.set(key, stored.snapshot);
|
||||
if (stored.owner) sessionOwners.set(key, stored.owner);
|
||||
if (stored.snapshot.status.active) {
|
||||
ownedRecordings.set(key, {
|
||||
target: stored.snapshot.status.target,
|
||||
owner: stored.owner || { kind: 'local' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return sessionRestorePromise;
|
||||
}
|
||||
|
||||
async function readSession(target: BrowserTarget): Promise<StoredRecordingSession | undefined> {
|
||||
await ensureSessionsRestored();
|
||||
const key = targetKey(target);
|
||||
const memory = latestSnapshots.get(key);
|
||||
if (memory) return { snapshot: memory, owner: ownedRecordings.get(key)?.owner || sessionOwners.get(key) };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function writeSession(snapshot: BrowserRecordingSnapshot, owner?: OwnedRecording['owner']): Promise<void> {
|
||||
await ensureSessionsRestored();
|
||||
if (removedTabs.has(snapshot.status.target.tabId)) return;
|
||||
const key = targetKey(snapshot.status.target);
|
||||
latestSnapshots.set(key, snapshot);
|
||||
const resolvedOwner = owner || ownedRecordings.get(key)?.owner || sessionOwners.get(key);
|
||||
if (resolvedOwner) sessionOwners.set(key, resolvedOwner);
|
||||
sessionStorageQueue = sessionStorageQueue.then(async () => {
|
||||
const sessions = await readStoredSessions();
|
||||
sessions[key] = { snapshot, owner: resolvedOwner };
|
||||
try { await browser.storage.session.set({ [RECORDING_SESSION_STORAGE_KEY]: sessions }); } catch { /* MV2 can lack storage.session. */ }
|
||||
});
|
||||
await sessionStorageQueue;
|
||||
}
|
||||
|
||||
async function removeSession(target: BrowserTarget): Promise<void> {
|
||||
await ensureSessionsRestored();
|
||||
const key = targetKey(target);
|
||||
latestSnapshots.delete(key);
|
||||
sessionOwners.delete(key);
|
||||
sessionStorageQueue = sessionStorageQueue.then(async () => {
|
||||
const sessions = await readStoredSessions();
|
||||
if (!(key in sessions)) return;
|
||||
delete sessions[key];
|
||||
try { await browser.storage.session.set({ [RECORDING_SESSION_STORAGE_KEY]: sessions }); } catch { /* MV2 can lack storage.session. */ }
|
||||
});
|
||||
await sessionStorageQueue;
|
||||
}
|
||||
|
||||
async function removeSessionsForTab(tabId: number): Promise<void> {
|
||||
await ensureSessionsRestored();
|
||||
for (const [key, recording] of ownedRecordings) {
|
||||
if (recording.target.tabId === tabId) ownedRecordings.delete(key);
|
||||
}
|
||||
for (const [key, snapshot] of latestSnapshots) {
|
||||
if (snapshot.status.target.tabId === tabId) {
|
||||
latestSnapshots.delete(key);
|
||||
sessionOwners.delete(key);
|
||||
}
|
||||
}
|
||||
sessionStorageQueue = sessionStorageQueue.then(async () => {
|
||||
const sessions = await readStoredSessions();
|
||||
let changed = false;
|
||||
for (const [key, stored] of Object.entries(sessions)) {
|
||||
if (stored.snapshot.status.target.tabId !== tabId) continue;
|
||||
delete sessions[key];
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
try { await browser.storage.session.set({ [RECORDING_SESSION_STORAGE_KEY]: sessions }); } catch { /* MV2 can lack storage.session. */ }
|
||||
}
|
||||
});
|
||||
await sessionStorageQueue;
|
||||
}
|
||||
|
||||
function enqueueLifecycle(target: BrowserTarget, task: () => Promise<void>): void {
|
||||
const key = targetKey(target);
|
||||
const previous = lifecycleQueues.get(key) || Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(task).finally(() => {
|
||||
if (lifecycleQueues.get(key) === next) lifecycleQueues.delete(key);
|
||||
});
|
||||
lifecycleQueues.set(key, next);
|
||||
}
|
||||
|
||||
function notifyRecordingChanged(tabId: number, reason: 'navigation' | 'restored' | 'updated'): void {
|
||||
void browser.runtime.sendMessage({ action: 'recording.changed', payload: { tabId, reason } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function pageRecorderCommand(
|
||||
registryKey: string,
|
||||
protocolVersion: number,
|
||||
command: RecorderCommand,
|
||||
input: Record<string, unknown>,
|
||||
): unknown {
|
||||
const controller = (window as unknown as Record<string, unknown>)[registryKey] as {
|
||||
version?: unknown;
|
||||
command?: (name: RecorderCommand, params: Record<string, unknown>) => unknown;
|
||||
} | undefined;
|
||||
if (controller?.version !== protocolVersion || typeof controller.command !== 'function') {
|
||||
if (['start', 'status', 'get', 'stop', 'resume', 'clear', 'navigation.record'].includes(command)) {
|
||||
return { version: protocolVersion, active: false, count: 0, droppedCount: 0, events: [], callables: [] };
|
||||
}
|
||||
throw new Error('页面录制器未安装');
|
||||
}
|
||||
return controller.command(command, input);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
return typeof value === 'string' ? value.slice(0, maxLength) : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptions(input?: Partial<BrowserRecordingOptions>): BrowserRecordingOptions {
|
||||
return {
|
||||
captureValues: input?.captureValues === true,
|
||||
maxEntries: Math.max(20, Math.min(Math.floor(input?.maxEntries || DEFAULT_OPTIONS.maxEntries), MAX_ENTRIES)),
|
||||
maxValueBytes: Math.max(256, Math.min(Math.floor(input?.maxValueBytes || DEFAULT_OPTIONS.maxValueBytes), 8_192)),
|
||||
expiresAt: typeof input?.expiresAt === 'number' && Number.isFinite(input.expiresAt) ? input.expiresAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvidence(value: unknown, allowSensitive: boolean): BrowserRecordingValueEvidence | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.path !== 'string' || typeof input.fingerprint !== 'string'
|
||||
|| !['text', 'bytes', 'hex', 'base64', 'json'].includes(String(input.encoding))) return undefined;
|
||||
return {
|
||||
path: input.path.slice(0, 512),
|
||||
fingerprint: input.fingerprint.slice(0, 160),
|
||||
encoding: input.encoding as BrowserRecordingValueEvidence['encoding'],
|
||||
byteLength: Math.max(0, Math.floor(finiteNumber(input.byteLength))),
|
||||
preview: allowSensitive ? optionalString(input.preview, 8_192) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCallArgument(value: unknown): BrowserRecordingCallArgument | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const roles: BrowserRecordingCallArgument['role'][] = [
|
||||
'data', 'key', 'iv', 'algorithm', 'options', 'signature', 'salt', 'nonce', 'aad', 'unknown',
|
||||
];
|
||||
if (!Number.isSafeInteger(input.index) || Number(input.index) < 0 || Number(input.index) > 63
|
||||
|| !roles.includes(input.role as BrowserRecordingCallArgument['role']) || typeof input.dataType !== 'string') return undefined;
|
||||
return {
|
||||
index: Number(input.index),
|
||||
role: input.role as BrowserRecordingCallArgument['role'],
|
||||
dataType: input.dataType.slice(0, 120),
|
||||
byteLength: input.byteLength === undefined ? undefined : Math.max(0, Math.floor(finiteNumber(input.byteLength))),
|
||||
replaceable: input.replaceable === true,
|
||||
retained: input.retained === true,
|
||||
summary: optionalString(input.summary, 240),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNavigation(value: unknown): BrowserRecordingNavigation | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const phases: BrowserRecordingNavigation['phase'][] = ['started', 'committed', 'completed', 'restored', 'same-document', 'failed'];
|
||||
const kinds: BrowserRecordingNavigation['kind'][] = ['document', 'history', 'fragment', 'reload', 'back-forward'];
|
||||
if (!phases.includes(input.phase as BrowserRecordingNavigation['phase'])
|
||||
|| !kinds.includes(input.kind as BrowserRecordingNavigation['kind'])
|
||||
|| typeof input.toUrl !== 'string') return undefined;
|
||||
return {
|
||||
phase: input.phase as BrowserRecordingNavigation['phase'],
|
||||
kind: input.kind as BrowserRecordingNavigation['kind'],
|
||||
fromUrl: optionalString(input.fromUrl, 8_192),
|
||||
toUrl: input.toUrl.slice(0, 8_192),
|
||||
sameDocument: input.sameDocument === true,
|
||||
transitionType: optionalString(input.transitionType, 120),
|
||||
transitionQualifiers: Array.isArray(input.transitionQualifiers)
|
||||
? input.transitionQualifiers.filter((item): item is string => typeof item === 'string').slice(0, 16).map((item) => item.slice(0, 120))
|
||||
: undefined,
|
||||
previousDocumentId: optionalString(input.previousDocumentId, 160),
|
||||
documentId: optionalString(input.documentId, 160),
|
||||
error: optionalString(input.error, 512),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransform(value: unknown): BrowserRecordingEvent['transform'] {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const categories: NonNullable<BrowserRecordingEvent['transform']>['category'][] = [
|
||||
'serializer', 'canonicalization', 'request-builder', 'encoding',
|
||||
];
|
||||
const providers: NonNullable<BrowserRecordingEvent['transform']>['provider'][] = ['native', 'axios', 'page'];
|
||||
const phases: NonNullable<BrowserRecordingEvent['transform']>['phase'][] = ['input', 'output', 'boundary'];
|
||||
if (!categories.includes(input.category as NonNullable<BrowserRecordingEvent['transform']>['category'])
|
||||
|| !providers.includes(input.provider as NonNullable<BrowserRecordingEvent['transform']>['provider'])) return undefined;
|
||||
return {
|
||||
category: input.category as NonNullable<BrowserRecordingEvent['transform']>['category'],
|
||||
provider: input.provider as NonNullable<BrowserRecordingEvent['transform']>['provider'],
|
||||
phase: phases.includes(input.phase as NonNullable<BrowserRecordingEvent['transform']>['phase'])
|
||||
? input.phase as NonNullable<BrowserRecordingEvent['transform']>['phase'] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(value: unknown, allowSensitive: boolean): BrowserRecordingEvent | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const kinds = ['interaction', 'fetch', 'xhr', 'form', 'beacon', 'worker', 'message', 'websocket', 'crypto', 'transform', 'navigation'] as const;
|
||||
if (typeof input.id !== 'string' || typeof input.recordingId !== 'string' || typeof input.traceId !== 'string'
|
||||
|| !kinds.includes(input.kind as typeof kinds[number]) || typeof input.operation !== 'string') return undefined;
|
||||
const crypto = normalizeBrowserRecordingCrypto(input.crypto);
|
||||
if (input.kind === 'crypto' && !crypto) return undefined;
|
||||
const output: BrowserRecordingEvent = {
|
||||
id: input.id.slice(0, 160),
|
||||
sequence: Math.max(0, Math.floor(finiteNumber(input.sequence))),
|
||||
timestamp: finiteNumber(input.timestamp),
|
||||
durationMs: input.durationMs === undefined ? undefined : Math.max(0, finiteNumber(input.durationMs)),
|
||||
recordingId: input.recordingId.slice(0, 160),
|
||||
traceId: input.traceId.slice(0, 160),
|
||||
interactionId: optionalString(input.interactionId, 160),
|
||||
parentEventId: optionalString(input.parentEventId, 160),
|
||||
kind: input.kind as BrowserRecordingEvent['kind'],
|
||||
source: input.source === 'browser' ? 'browser' : 'page',
|
||||
documentId: optionalString(input.documentId, 160),
|
||||
operation: input.operation.slice(0, 160),
|
||||
inputs: Array.isArray(input.inputs)
|
||||
? input.inputs.slice(0, 48).map((item) => normalizeEvidence(item, allowSensitive)).filter((item): item is BrowserRecordingValueEvidence => Boolean(item))
|
||||
: [],
|
||||
outputs: Array.isArray(input.outputs)
|
||||
? input.outputs.slice(0, 48).map((item) => normalizeEvidence(item, allowSensitive)).filter((item): item is BrowserRecordingValueEvidence => Boolean(item))
|
||||
: [],
|
||||
arguments: Array.isArray(input.arguments)
|
||||
? input.arguments.slice(0, 64).map(normalizeCallArgument).filter((item): item is BrowserRecordingCallArgument => Boolean(item))
|
||||
: undefined,
|
||||
sensitiveCaptured: allowSensitive && input.sensitiveCaptured === true,
|
||||
navigation: normalizeNavigation(input.navigation),
|
||||
crypto,
|
||||
transform: normalizeTransform(input.transform),
|
||||
};
|
||||
const stringLimits: Record<string, number> = {
|
||||
label: 240, url: 8_192, method: 32, socketId: 160, channelId: 160, dataType: 120,
|
||||
stack: 4_096, scriptUrl: 2_048, wrapperHandleId: 160, callHandleId: 160, error: 512,
|
||||
};
|
||||
for (const [key, limit] of Object.entries(stringLimits)) {
|
||||
const normalized = optionalString(input[key], limit);
|
||||
if (normalized !== undefined) (output as unknown as Record<string, unknown>)[key] = normalized;
|
||||
}
|
||||
if (input.direction === 'send' || input.direction === 'receive') output.direction = input.direction;
|
||||
if (typeof input.callableCapable === 'boolean') output.callableCapable = input.callableCapable;
|
||||
for (const key of ['byteLength', 'resultByteLength'] as const) {
|
||||
if (input[key] !== undefined) output[key] = Math.max(0, finiteNumber(input[key]));
|
||||
}
|
||||
if (allowSensitive) {
|
||||
output.inputPreview = optionalString(input.inputPreview, 8_192);
|
||||
output.outputPreview = optionalString(input.outputPreview, 8_192);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeRawSnapshot(value: unknown, allowSensitive: boolean): RawRecorderSnapshot {
|
||||
if (!value || typeof value !== 'object') throw new ExtensionError('recorder_unavailable', '页面录制器返回了无效状态');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (input.version !== PAGE_RECORDER_PROTOCOL_VERSION || typeof input.active !== 'boolean' || !Array.isArray(input.events) || !Array.isArray(input.callables)) {
|
||||
throw new ExtensionError('recorder_unavailable', '页面录制器协议不兼容');
|
||||
}
|
||||
return {
|
||||
version: PAGE_RECORDER_PROTOCOL_VERSION,
|
||||
active: input.active,
|
||||
recordingId: optionalString(input.recordingId, 160),
|
||||
startedAt: input.startedAt === undefined ? undefined : finiteNumber(input.startedAt),
|
||||
count: Math.max(0, Math.floor(finiteNumber(input.count))),
|
||||
droppedCount: Math.max(0, Math.floor(finiteNumber(input.droppedCount))),
|
||||
options: input.options && typeof input.options === 'object' ? normalizeOptions(input.options as Partial<BrowserRecordingOptions>) : undefined,
|
||||
events: input.events.slice(-MAX_ENTRIES).map((item) => normalizeEvent(item, allowSensitive)).filter((item): item is BrowserRecordingEvent => Boolean(item)),
|
||||
callables: input.callables.slice(0, 128),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCommand(target: BrowserTarget, command: RecorderCommand, input: Record<string, unknown> = {}): Promise<unknown> {
|
||||
let results: Browser.scripting.InjectionResult[];
|
||||
try {
|
||||
results = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: pageRecorderCommand,
|
||||
args: [PAGE_RECORDER_REGISTRY_KEY, PAGE_RECORDER_PROTOCOL_VERSION, command, input],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ExtensionError('recorder_unavailable', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (results.length !== 1) throw new ExtensionError('recorder_unavailable', '页面录制器无法唯一定位目标文档');
|
||||
return results[0].result;
|
||||
}
|
||||
|
||||
async function install(target: BrowserTarget): Promise<void> {
|
||||
try {
|
||||
await browser.scripting.executeScript({ target: scriptingTarget(target), world: 'MAIN', files: [RECORDER_SCRIPT] });
|
||||
} catch (error) {
|
||||
throw new ExtensionError('recorder_unavailable', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function statusFrom(target: BrowserTarget, raw: RawRecorderSnapshot): BrowserRecordingStatus {
|
||||
const expired = Boolean(raw.options?.expiresAt && raw.options.expiresAt <= Date.now());
|
||||
return {
|
||||
active: raw.active, target, documentAvailable: true, recordingId: raw.recordingId, startedAt: raw.startedAt,
|
||||
count: raw.count, droppedCount: raw.droppedCount, options: raw.options,
|
||||
endedReason: raw.startedAt && !raw.active ? (expired ? 'expired' : 'user') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotFromEvents(
|
||||
target: BrowserTarget,
|
||||
status: BrowserRecordingStatus,
|
||||
events: BrowserRecordingEvent[],
|
||||
callables: BrowserPageCallable[],
|
||||
): BrowserRecordingSnapshot {
|
||||
const links = buildRecordingLinks(events);
|
||||
return {
|
||||
status: { ...status, target, count: events.length },
|
||||
events,
|
||||
links,
|
||||
traces: buildRecordingTraces(events, links),
|
||||
callables,
|
||||
profileCandidates: inferBrowserTransformProfiles({ target, events, links }),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotFrom(target: BrowserTarget, raw: RawRecorderSnapshot): BrowserRecordingSnapshot {
|
||||
const events = raw.events.map((event) => event.documentId || !target.documentId
|
||||
? event
|
||||
: { ...event, documentId: target.documentId });
|
||||
const callables = raw.callables
|
||||
.map((item) => normalizeCallable(item, target))
|
||||
.filter((item): item is BrowserPageCallable => Boolean(item));
|
||||
return snapshotFromEvents(target, statusFrom(target, raw), events, callables);
|
||||
}
|
||||
|
||||
function mergeSessionSnapshot(
|
||||
target: BrowserTarget,
|
||||
raw: RawRecorderSnapshot,
|
||||
previous?: BrowserRecordingSnapshot,
|
||||
status?: Partial<BrowserRecordingStatus>,
|
||||
): BrowserRecordingSnapshot {
|
||||
const current = snapshotFrom(target, raw);
|
||||
const sameSession = Boolean(previous?.status.recordingId && previous.status.recordingId === raw.recordingId);
|
||||
const events = sameSession
|
||||
? mergeRecordingEvents([current.events, previous?.events || []])
|
||||
: current.events;
|
||||
return snapshotFromEvents(target, {
|
||||
...current.status,
|
||||
...(sameSession ? {
|
||||
startedAt: previous?.status.startedAt || current.status.startedAt,
|
||||
droppedCount: Math.max(previous?.status.droppedCount || 0, current.status.droppedCount),
|
||||
options: current.status.options || previous?.status.options,
|
||||
pageUrl: previous?.status.pageUrl,
|
||||
navigation: previous?.status.navigation,
|
||||
} : {}),
|
||||
...status,
|
||||
}, events, current.callables);
|
||||
}
|
||||
|
||||
interface NavigationDetails {
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
url: string;
|
||||
timeStamp: number;
|
||||
transitionType?: string;
|
||||
transitionQualifiers?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function uniqueToken(prefix: string): string {
|
||||
const value = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `${prefix}-${value}`;
|
||||
}
|
||||
|
||||
async function currentPageUrl(target: BrowserTarget): Promise<string | undefined> {
|
||||
try {
|
||||
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
|
||||
if (frame?.url) return frame.url.slice(0, 8_192);
|
||||
} catch { /* The frame can disappear while a navigation is committing. */ }
|
||||
if (target.frameId !== 0) return undefined;
|
||||
try { return (await browser.tabs.get(target.tabId)).url?.slice(0, 8_192); } catch { return undefined; }
|
||||
}
|
||||
|
||||
function navigationLabel(navigation: BrowserRecordingNavigation): string {
|
||||
if (navigation.kind === 'back-forward') return '浏览器前进或后退';
|
||||
if (navigation.kind === 'reload') return '重新加载页面';
|
||||
if (navigation.kind === 'history') return '页面路由变化';
|
||||
if (navigation.kind === 'fragment') return '页面锚点变化';
|
||||
return '页面跳转';
|
||||
}
|
||||
|
||||
function navigationOperation(navigation: BrowserRecordingNavigation): string {
|
||||
if (navigation.kind === 'back-forward') return 'history.traverse';
|
||||
if (navigation.kind === 'reload') return 'navigation.reload';
|
||||
if (navigation.kind === 'history') return 'history.state';
|
||||
if (navigation.kind === 'fragment') return 'location.fragment';
|
||||
return 'navigation.document';
|
||||
}
|
||||
|
||||
function navigationKind(details: Pick<NavigationDetails, 'transitionType' | 'transitionQualifiers'>): BrowserRecordingNavigation['kind'] {
|
||||
if (details.transitionQualifiers?.includes('forward_back')) return 'back-forward';
|
||||
if (details.transitionType === 'reload') return 'reload';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
function applyNavigation(
|
||||
snapshot: BrowserRecordingSnapshot,
|
||||
target: BrowserTarget,
|
||||
navigation: BrowserRecordingNavigation,
|
||||
timestamp: number,
|
||||
input?: { eventId?: string; documentAvailable?: boolean; callables?: BrowserPageCallable[]; active?: boolean },
|
||||
): BrowserRecordingSnapshot {
|
||||
const hasExplicitEvent = Boolean(input && Object.prototype.hasOwnProperty.call(input, 'eventId'));
|
||||
const existingId = hasExplicitEvent ? input?.eventId : snapshot.status.navigation?.eventId;
|
||||
const existing = existingId
|
||||
? snapshot.events.find((event) => event.id === existingId)
|
||||
: snapshot.events.findLast((event) => event.kind === 'navigation' && event.navigation?.toUrl === navigation.toUrl);
|
||||
const eventId = existing?.id || uniqueToken('event-navigation');
|
||||
const standalone = navigation.kind === 'back-forward';
|
||||
const traceId = standalone
|
||||
? `trace-${eventId}`
|
||||
: existing?.traceId || latestRecordingTraceId(snapshot.events) || uniqueToken('trace-navigation');
|
||||
const startedAt = existing?.timestamp || timestamp;
|
||||
const event: BrowserRecordingEvent = {
|
||||
id: eventId,
|
||||
sequence: existing?.sequence || nextRecordingSequence(snapshot.events),
|
||||
timestamp: startedAt,
|
||||
durationMs: navigation.phase === 'started' || navigation.phase === 'same-document'
|
||||
? existing?.durationMs
|
||||
: Math.max(0, timestamp - startedAt),
|
||||
recordingId: snapshot.status.recordingId || existing?.recordingId || uniqueToken('recording'),
|
||||
traceId,
|
||||
interactionId: standalone ? undefined : existing?.interactionId,
|
||||
parentEventId: existing?.parentEventId,
|
||||
kind: 'navigation',
|
||||
source: 'browser',
|
||||
documentId: navigation.previousDocumentId || existing?.documentId,
|
||||
operation: navigationOperation(navigation),
|
||||
label: navigationLabel(navigation),
|
||||
url: navigation.toUrl,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
sensitiveCaptured: false,
|
||||
error: navigation.error,
|
||||
navigation,
|
||||
};
|
||||
const events = mergeRecordingEvents([
|
||||
snapshot.events.filter((item) => item.id !== eventId),
|
||||
[event],
|
||||
]);
|
||||
return snapshotFromEvents(target, {
|
||||
...snapshot.status,
|
||||
target,
|
||||
active: input?.active ?? snapshot.status.active,
|
||||
documentAvailable: input?.documentAvailable ?? snapshot.status.documentAvailable,
|
||||
pageUrl: navigation.phase === 'failed' ? navigation.fromUrl : navigation.toUrl,
|
||||
endedReason: undefined,
|
||||
navigation: { ...navigation, eventId, timestamp: startedAt },
|
||||
}, events, input?.callables ?? snapshot.callables);
|
||||
}
|
||||
|
||||
export async function startBrowserRecording(
|
||||
target: BrowserTarget,
|
||||
input?: Partial<BrowserRecordingOptions>,
|
||||
owner: OwnedRecording['owner'] = { kind: 'local' },
|
||||
): Promise<BrowserRecordingSnapshot> {
|
||||
const options = normalizeOptions(input);
|
||||
await removeSession(target);
|
||||
await install(target);
|
||||
const raw = normalizeRawSnapshot(await executeCommand(target, 'start', { ...options }), options.captureValues);
|
||||
if (!raw.startedAt) throw new ExtensionError('recorder_unavailable', '页面录制器尚未在目标文档就绪');
|
||||
ownedRecordings.set(targetKey(target), { target, owner });
|
||||
const snapshot = snapshotFrom(target, raw);
|
||||
snapshot.status.pageUrl = await currentPageUrl(target);
|
||||
await writeSession(snapshot, owner);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function browserRecordingStatus(target: BrowserTarget): Promise<BrowserRecordingStatus> {
|
||||
const session = await readSession(target);
|
||||
let raw = normalizeRawSnapshot(await executeCommand(target, 'status'), false);
|
||||
if (session?.snapshot.status.active && raw.startedAt && !raw.active
|
||||
&& raw.recordingId === session.snapshot.status.recordingId
|
||||
&& (!raw.options?.expiresAt || raw.options.expiresAt > Date.now())) {
|
||||
raw = normalizeRawSnapshot(await executeCommand(target, 'resume', {
|
||||
sequenceStart: nextRecordingSequence(session.snapshot.events) - 1,
|
||||
}), false);
|
||||
}
|
||||
if (raw.startedAt) {
|
||||
const expired = Boolean(raw.options?.expiresAt && raw.options.expiresAt <= Date.now());
|
||||
const merged = mergeSessionSnapshot(target, raw, session?.snapshot, expired
|
||||
? { active: false, documentAvailable: true, endedReason: 'expired' }
|
||||
: session?.snapshot.status.active
|
||||
? { active: true, documentAvailable: true, endedReason: undefined }
|
||||
: undefined);
|
||||
latestSnapshots.set(targetKey(target), merged);
|
||||
if (expired) {
|
||||
ownedRecordings.delete(targetKey(target));
|
||||
await writeSession(merged, session?.owner);
|
||||
}
|
||||
if (merged.status.active && !ownedRecordings.has(targetKey(target))) {
|
||||
ownedRecordings.set(targetKey(target), { target, owner: session?.owner || { kind: 'local' } });
|
||||
}
|
||||
return merged.status;
|
||||
}
|
||||
return session?.snapshot.status || statusFrom(target, raw);
|
||||
}
|
||||
|
||||
export async function getBrowserRecording(target: BrowserTarget, limit = MAX_ENTRIES, allowSensitive = false): Promise<BrowserRecordingSnapshot> {
|
||||
const session = await readSession(target);
|
||||
let raw = normalizeRawSnapshot(await executeCommand(target, 'get', { limit: Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)) }), allowSensitive);
|
||||
if (session?.snapshot.status.active && raw.startedAt && !raw.active
|
||||
&& raw.recordingId === session.snapshot.status.recordingId
|
||||
&& (!raw.options?.expiresAt || raw.options.expiresAt > Date.now())) {
|
||||
raw = normalizeRawSnapshot(await executeCommand(target, 'resume', {
|
||||
sequenceStart: nextRecordingSequence(session.snapshot.events) - 1,
|
||||
}), allowSensitive);
|
||||
}
|
||||
if (!raw.startedAt) {
|
||||
if (session) return session.snapshot;
|
||||
}
|
||||
const expired = Boolean(raw.options?.expiresAt && raw.options.expiresAt <= Date.now());
|
||||
const snapshot = mergeSessionSnapshot(target, raw, session?.snapshot, expired
|
||||
? { active: false, documentAvailable: true, endedReason: 'expired' }
|
||||
: session?.snapshot.status.active
|
||||
? { active: true, documentAvailable: true, endedReason: undefined }
|
||||
: undefined);
|
||||
latestSnapshots.set(targetKey(target), snapshot);
|
||||
if (expired) {
|
||||
ownedRecordings.delete(targetKey(target));
|
||||
await writeSession(snapshot, session?.owner);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function clearBrowserRecording(target: BrowserTarget, allowSensitive = false): Promise<BrowserRecordingSnapshot> {
|
||||
const raw = normalizeRawSnapshot(await executeCommand(target, 'clear').catch(() => ({
|
||||
version: PAGE_RECORDER_PROTOCOL_VERSION, active: false, count: 0, droppedCount: 0, events: [], callables: [],
|
||||
})), allowSensitive);
|
||||
ownedRecordings.delete(targetKey(target));
|
||||
await removeSession(target);
|
||||
return snapshotFrom(target, raw);
|
||||
}
|
||||
|
||||
export async function stopBrowserRecording(target: BrowserTarget, allowSensitive = false): Promise<BrowserRecordingSnapshot> {
|
||||
const session = await readSession(target);
|
||||
const raw = normalizeRawSnapshot(await executeCommand(target, 'stop').catch(() => ({
|
||||
version: PAGE_RECORDER_PROTOCOL_VERSION, active: false, count: 0, droppedCount: 0, events: [], callables: [],
|
||||
})), allowSensitive);
|
||||
ownedRecordings.delete(targetKey(target));
|
||||
const snapshot = raw.startedAt
|
||||
? mergeSessionSnapshot(target, raw, session?.snapshot, { active: false, documentAvailable: true, endedReason: 'user' })
|
||||
: session
|
||||
? snapshotFromEvents(session.snapshot.status.target, {
|
||||
...session.snapshot.status,
|
||||
active: false,
|
||||
endedReason: 'user',
|
||||
}, session.snapshot.events, [])
|
||||
: snapshotFrom(target, raw);
|
||||
await writeSession(snapshot, session?.owner);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function createRecordedPageCallable(
|
||||
target: BrowserTarget,
|
||||
input: { callHandleId: string; name: string },
|
||||
): Promise<BrowserPageCallable> {
|
||||
const raw = await executeCommand(target, 'callable.create', input);
|
||||
const callable = normalizeCallable(raw, target);
|
||||
if (!callable) throw new ExtensionError('callable_invalid', '页面返回了无效函数');
|
||||
return callable;
|
||||
}
|
||||
|
||||
export async function armBrowserRecordingDeepBreak(
|
||||
target: BrowserTarget,
|
||||
matcher: Extract<BrowserDeepCaptureMatcher, { kind: 'crypto' | 'boundary' }>,
|
||||
): Promise<void> {
|
||||
await executeCommand(target, 'deep.arm', matcher);
|
||||
}
|
||||
|
||||
export async function disarmBrowserRecordingDeepBreak(target: BrowserTarget): Promise<void> {
|
||||
await executeCommand(target, 'deep.disarm').catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function stopBrowserRecordingsForGrant(grantId: string): Promise<void> {
|
||||
await ensureSessionsRestored();
|
||||
const targets = new Map<string, BrowserTarget>();
|
||||
for (const [key, item] of ownedRecordings) {
|
||||
if (item.owner.kind === 'grant' && item.owner.grantId === grantId) targets.set(key, item.target);
|
||||
}
|
||||
for (const [key, owner] of sessionOwners) {
|
||||
if (owner.kind !== 'grant' || owner.grantId !== grantId) continue;
|
||||
const target = latestSnapshots.get(key)?.status.target;
|
||||
if (target) targets.set(key, target);
|
||||
}
|
||||
await Promise.allSettled([...targets.values()].map((target) => clearBrowserRecording(target)));
|
||||
}
|
||||
|
||||
export async function recordingAnalysisWindow(target: BrowserTarget, centerTimestamp: number): Promise<Array<Pick<
|
||||
BrowserRecordingEvent,
|
||||
'kind' | 'operation' | 'crypto' | 'direction' | 'scriptUrl' | 'byteLength' | 'resultByteLength' | 'timestamp'
|
||||
>>> {
|
||||
const snapshot = await getBrowserRecording(target, MAX_ENTRIES, false).catch(() => undefined);
|
||||
return (snapshot?.events || []).filter((item) => Math.abs(item.timestamp - centerTimestamp) <= 60_000).map((item) => ({
|
||||
kind: item.kind,
|
||||
operation: item.operation,
|
||||
crypto: item.crypto,
|
||||
direction: item.direction,
|
||||
scriptUrl: item.scriptUrl,
|
||||
byteLength: item.byteLength,
|
||||
resultByteLength: item.resultByteLength,
|
||||
timestamp: item.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
async function archiveBeforeNavigation(details: NavigationDetails): Promise<void> {
|
||||
const keyTarget: BrowserTarget = { tabId: details.tabId, frameId: details.frameId };
|
||||
const key = targetKey(keyTarget);
|
||||
const stored = await readSession(keyTarget);
|
||||
const known = ownedRecordings.get(key);
|
||||
const pageTarget = known?.target || stored?.snapshot.status.target || keyTarget;
|
||||
let snapshot = stored?.snapshot;
|
||||
let raw: RawRecorderSnapshot | undefined;
|
||||
|
||||
try {
|
||||
raw = normalizeRawSnapshot(await executeCommand(pageTarget, 'get', { limit: MAX_ENTRIES }), true);
|
||||
if (raw.startedAt) snapshot = mergeSessionSnapshot(pageTarget, raw, snapshot);
|
||||
} catch {
|
||||
// The renderer may commit before the final snapshot reaches the service
|
||||
// worker. The latest bounded session still preserves prior evidence.
|
||||
}
|
||||
|
||||
if (!snapshot?.status.startedAt || (!snapshot.status.active && !raw?.active)) return;
|
||||
const owner = known?.owner || stored?.owner || { kind: 'local' as const };
|
||||
ownedRecordings.set(key, { target: pageTarget, owner });
|
||||
const fromUrl = snapshot.status.pageUrl || await currentPageUrl(pageTarget);
|
||||
const navigation: BrowserRecordingNavigation = {
|
||||
phase: 'started',
|
||||
kind: 'document',
|
||||
fromUrl,
|
||||
toUrl: details.url.slice(0, 8_192),
|
||||
sameDocument: false,
|
||||
previousDocumentId: pageTarget.documentId,
|
||||
};
|
||||
|
||||
try {
|
||||
const recorded = normalizeRawSnapshot(await executeCommand(pageTarget, 'navigation.record', {
|
||||
navigation,
|
||||
documentId: pageTarget.documentId,
|
||||
operation: navigationOperation(navigation),
|
||||
label: navigationLabel(navigation),
|
||||
}), true);
|
||||
if (recorded.startedAt) {
|
||||
snapshot = mergeSessionSnapshot(pageTarget, recorded, snapshot, {
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
endedReason: undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A synthetic browser event below records the boundary when the page wins
|
||||
// the navigation race before MAIN-world execution completes.
|
||||
}
|
||||
|
||||
await executeCommand(pageTarget, 'stop').catch(() => undefined);
|
||||
const pageNavigation = [...snapshot.events].reverse().find((event) => (
|
||||
event.kind === 'navigation' && event.navigation?.phase === 'started' && event.navigation.toUrl === navigation.toUrl
|
||||
));
|
||||
const continuesAcrossDocuments = owner.kind === 'local';
|
||||
const archived = applyNavigation(snapshot, pageTarget, navigation, details.timeStamp || Date.now(), {
|
||||
eventId: pageNavigation?.id,
|
||||
active: continuesAcrossDocuments,
|
||||
documentAvailable: false,
|
||||
callables: [],
|
||||
});
|
||||
if (!continuesAcrossDocuments) {
|
||||
archived.status.endedReason = 'authorization';
|
||||
ownedRecordings.delete(key);
|
||||
}
|
||||
await writeSession(archived, owner);
|
||||
notifyRecordingChanged(details.tabId, 'navigation');
|
||||
}
|
||||
|
||||
async function continueRecordingOnDocument(
|
||||
details: NavigationDetails,
|
||||
phase: 'committed' | 'completed',
|
||||
): Promise<void> {
|
||||
const target: BrowserTarget = {
|
||||
tabId: details.tabId,
|
||||
frameId: details.frameId,
|
||||
documentId: details.documentId,
|
||||
};
|
||||
const key = targetKey(target);
|
||||
const stored = await readSession(target);
|
||||
if (!stored?.snapshot.status.active || !stored.snapshot.status.recordingId) return;
|
||||
const previous = stored.snapshot;
|
||||
const previousNavigation = previous.status.navigation;
|
||||
const hasTransitionEvidence = Boolean(details.transitionType || details.transitionQualifiers?.length);
|
||||
const kind = hasTransitionEvidence
|
||||
? navigationKind(details)
|
||||
: previousNavigation?.kind || 'document';
|
||||
const navigation: BrowserRecordingNavigation = {
|
||||
phase,
|
||||
kind,
|
||||
fromUrl: previousNavigation?.fromUrl || previous.status.pageUrl,
|
||||
toUrl: details.url.slice(0, 8_192),
|
||||
sameDocument: false,
|
||||
transitionType: details.transitionType || previousNavigation?.transitionType,
|
||||
transitionQualifiers: details.transitionQualifiers?.length
|
||||
? details.transitionQualifiers
|
||||
: previousNavigation?.transitionQualifiers,
|
||||
previousDocumentId: previousNavigation?.previousDocumentId || previous.status.target.documentId,
|
||||
documentId: details.documentId,
|
||||
};
|
||||
let staged = applyNavigation(previous, target, navigation, details.timeStamp || Date.now(), {
|
||||
eventId: previousNavigation?.eventId,
|
||||
active: true,
|
||||
documentAvailable: false,
|
||||
callables: [],
|
||||
});
|
||||
const options = staged.status.options || DEFAULT_OPTIONS;
|
||||
if (options.expiresAt && options.expiresAt <= Date.now()) {
|
||||
staged = snapshotFromEvents(target, {
|
||||
...staged.status,
|
||||
active: false,
|
||||
documentAvailable: false,
|
||||
endedReason: 'expired',
|
||||
}, staged.events, []);
|
||||
ownedRecordings.delete(key);
|
||||
await writeSession(staged, stored.owner);
|
||||
notifyRecordingChanged(details.tabId, 'updated');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await install(target);
|
||||
let raw = normalizeRawSnapshot(await executeCommand(target, 'get', { limit: MAX_ENTRIES }), true);
|
||||
const wasRestored = phase === 'committed'
|
||||
&& kind === 'back-forward'
|
||||
&& !previous.status.documentAvailable
|
||||
&& raw.startedAt
|
||||
&& raw.recordingId === staged.status.recordingId;
|
||||
const sequenceStart = nextRecordingSequence(staged.events) - 1;
|
||||
if (raw.startedAt && raw.recordingId === staged.status.recordingId) {
|
||||
raw = normalizeRawSnapshot(await executeCommand(target, 'resume', { sequenceStart }), true);
|
||||
} else {
|
||||
raw = normalizeRawSnapshot(await executeCommand(target, 'start', {
|
||||
...options,
|
||||
recordingId: staged.status.recordingId,
|
||||
startedAt: staged.status.startedAt,
|
||||
sequenceStart,
|
||||
}), true);
|
||||
}
|
||||
if (!raw.startedAt) throw new ExtensionError('recorder_unavailable', '新页面录制器尚未就绪');
|
||||
const liveNavigation: BrowserRecordingNavigation = {
|
||||
...navigation,
|
||||
phase: wasRestored || previousNavigation?.phase === 'restored' ? 'restored' : phase,
|
||||
};
|
||||
staged = applyNavigation(staged, target, liveNavigation, details.timeStamp || Date.now(), {
|
||||
eventId: staged.status.navigation?.eventId,
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
callables: [],
|
||||
});
|
||||
const live = mergeSessionSnapshot(target, raw, staged, {
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
pageUrl: details.url.slice(0, 8_192),
|
||||
endedReason: undefined,
|
||||
navigation: staged.status.navigation,
|
||||
});
|
||||
const owner = ownedRecordings.get(key)?.owner || stored.owner || { kind: 'local' as const };
|
||||
ownedRecordings.set(key, { target, owner });
|
||||
await writeSession(live, owner);
|
||||
notifyRecordingChanged(details.tabId, wasRestored ? 'restored' : 'updated');
|
||||
} catch {
|
||||
await writeSession(staged, stored.owner);
|
||||
notifyRecordingChanged(details.tabId, 'updated');
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSameDocumentNavigation(
|
||||
details: NavigationDetails,
|
||||
kind: 'history' | 'fragment',
|
||||
): Promise<void> {
|
||||
const target: BrowserTarget = { tabId: details.tabId, frameId: details.frameId, documentId: details.documentId };
|
||||
const stored = await readSession(target);
|
||||
if (!stored?.snapshot.status.active || !stored.snapshot.status.recordingId) return;
|
||||
const navigation: BrowserRecordingNavigation = {
|
||||
phase: 'same-document',
|
||||
kind,
|
||||
fromUrl: stored.snapshot.status.pageUrl,
|
||||
toUrl: details.url.slice(0, 8_192),
|
||||
sameDocument: true,
|
||||
transitionType: details.transitionType,
|
||||
transitionQualifiers: details.transitionQualifiers,
|
||||
previousDocumentId: stored.snapshot.status.target.documentId,
|
||||
documentId: details.documentId || stored.snapshot.status.target.documentId,
|
||||
};
|
||||
let snapshot = stored.snapshot;
|
||||
try {
|
||||
const raw = normalizeRawSnapshot(await executeCommand(target, 'navigation.record', {
|
||||
navigation,
|
||||
documentId: navigation.documentId,
|
||||
operation: navigationOperation(navigation),
|
||||
label: navigationLabel(navigation),
|
||||
}), true);
|
||||
if (raw.startedAt) {
|
||||
snapshot = mergeSessionSnapshot(target, raw, snapshot, {
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
pageUrl: navigation.toUrl,
|
||||
endedReason: undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Restricted documents still receive a synthetic browser-level boundary.
|
||||
}
|
||||
const pageNavigation = [...snapshot.events].reverse().find((event) => (
|
||||
event.kind === 'navigation' && event.navigation?.toUrl === navigation.toUrl
|
||||
));
|
||||
snapshot = applyNavigation(snapshot, target, navigation, details.timeStamp || Date.now(), {
|
||||
eventId: pageNavigation?.id,
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
});
|
||||
await writeSession(snapshot, stored.owner);
|
||||
notifyRecordingChanged(details.tabId, 'updated');
|
||||
}
|
||||
|
||||
async function failNavigation(details: NavigationDetails): Promise<void> {
|
||||
const keyTarget: BrowserTarget = { tabId: details.tabId, frameId: details.frameId };
|
||||
const stored = await readSession(keyTarget);
|
||||
if (!stored?.snapshot.status.active || !stored.snapshot.status.navigation) return;
|
||||
const target = stored.snapshot.status.target;
|
||||
const navigation: BrowserRecordingNavigation = {
|
||||
...stored.snapshot.status.navigation,
|
||||
phase: 'failed',
|
||||
error: details.error?.slice(0, 512) || '页面跳转失败',
|
||||
};
|
||||
let snapshot = applyNavigation(stored.snapshot, target, navigation, details.timeStamp || Date.now(), {
|
||||
eventId: stored.snapshot.status.navigation.eventId,
|
||||
active: true,
|
||||
documentAvailable: false,
|
||||
callables: [],
|
||||
});
|
||||
try {
|
||||
const raw = normalizeRawSnapshot(await executeCommand(target, 'resume', {
|
||||
sequenceStart: nextRecordingSequence(snapshot.events) - 1,
|
||||
}), true);
|
||||
if (raw.startedAt) {
|
||||
snapshot = mergeSessionSnapshot(target, raw, snapshot, {
|
||||
active: true,
|
||||
documentAvailable: true,
|
||||
pageUrl: navigation.fromUrl,
|
||||
navigation: snapshot.status.navigation,
|
||||
});
|
||||
}
|
||||
} catch { /* The failure boundary remains visible even if the old renderer vanished. */ }
|
||||
await writeSession(snapshot, stored.owner);
|
||||
notifyRecordingChanged(details.tabId, 'updated');
|
||||
}
|
||||
|
||||
browser.webNavigation.onBeforeNavigate.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => archiveBeforeNavigation({ ...details, transitionQualifiers: undefined }));
|
||||
});
|
||||
|
||||
browser.webNavigation.onCommitted.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => continueRecordingOnDocument({
|
||||
...details,
|
||||
transitionQualifiers: details.transitionQualifiers ? [...details.transitionQualifiers] : undefined,
|
||||
}, 'committed'));
|
||||
});
|
||||
|
||||
browser.webNavigation.onDOMContentLoaded.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => continueRecordingOnDocument(details, 'committed'));
|
||||
});
|
||||
|
||||
browser.webNavigation.onCompleted.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => continueRecordingOnDocument(details, 'completed'));
|
||||
});
|
||||
|
||||
browser.webNavigation.onHistoryStateUpdated.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => recordSameDocumentNavigation(details, 'history'));
|
||||
});
|
||||
|
||||
browser.webNavigation.onReferenceFragmentUpdated.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => recordSameDocumentNavigation(details, 'fragment'));
|
||||
});
|
||||
|
||||
browser.webNavigation.onErrorOccurred.addListener((details) => {
|
||||
const target = { tabId: details.tabId, frameId: details.frameId };
|
||||
enqueueLifecycle(target, () => failNavigation(details));
|
||||
});
|
||||
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
removedTabs.add(tabId);
|
||||
for (const key of lifecycleQueues.keys()) {
|
||||
if (key.startsWith(`${tabId}:`)) lifecycleQueues.delete(key);
|
||||
}
|
||||
void removeSessionsForTab(tabId);
|
||||
});
|
||||
|
||||
browser.tabs.onCreated.addListener((tab) => {
|
||||
if (tab.id !== undefined) removedTabs.delete(tab.id);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserRecordingEvent } from '@/types/models';
|
||||
import {
|
||||
buildRecordingLinks,
|
||||
buildRecordingTraces,
|
||||
mergeRecordingEvents,
|
||||
nextRecordingSequence,
|
||||
} from './timeline';
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
sequence: number,
|
||||
traceId: string,
|
||||
overrides: Partial<BrowserRecordingEvent> = {},
|
||||
): BrowserRecordingEvent {
|
||||
return {
|
||||
id,
|
||||
sequence,
|
||||
timestamp: 1_000 + sequence * 10,
|
||||
recordingId: 'session-1',
|
||||
traceId,
|
||||
kind: 'interaction',
|
||||
operation: 'click',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
sensitiveCaptured: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('browser recording timeline', () => {
|
||||
it('keeps traces and their steps in top-to-bottom chronological order', () => {
|
||||
const events = [
|
||||
event('request', 3, 'trace-login', { kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/login' }),
|
||||
event('second-click', 5, 'trace-search', { label: '查询' }),
|
||||
event('first-click', 1, 'trace-login', { label: '登录' }),
|
||||
event('crypto', 2, 'trace-login', { kind: 'crypto', operation: 'AES.encrypt', crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' } }),
|
||||
event('navigation', 4, 'trace-login', {
|
||||
kind: 'navigation',
|
||||
operation: 'navigation.document',
|
||||
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
||||
}),
|
||||
];
|
||||
const traces = buildRecordingTraces(events, []);
|
||||
|
||||
expect(traces.map((trace) => trace.label)).toEqual(['登录', '查询']);
|
||||
expect(traces[0].eventIds).toEqual(['first-click', 'crypto', 'request', 'navigation']);
|
||||
expect(traces[0]).toMatchObject({ requestCount: 1, cryptoCount: 1, navigationCount: 1 });
|
||||
});
|
||||
|
||||
it('updates one navigation boundary instead of duplicating its lifecycle phases', () => {
|
||||
const started = event('navigation', 2, 'trace-login', {
|
||||
kind: 'navigation',
|
||||
operation: 'navigation.document',
|
||||
navigation: { phase: 'started', kind: 'document', fromUrl: 'https://example.test/', toUrl: 'https://example.test/success', sameDocument: false },
|
||||
});
|
||||
const completed = {
|
||||
...started,
|
||||
durationMs: 84,
|
||||
navigation: { ...started.navigation!, phase: 'completed' as const, documentId: 'document-2' },
|
||||
};
|
||||
const merged = mergeRecordingEvents([[started], [completed]]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].navigation?.phase).toBe('completed');
|
||||
expect(merged[0].durationMs).toBe(84);
|
||||
expect(nextRecordingSequence(merged)).toBe(3);
|
||||
expect(buildRecordingTraces(merged, [])[0]).toMatchObject({ startedAt: 1_020, endedAt: 1_104 });
|
||||
});
|
||||
|
||||
it('preserves sensitive previews and the most advanced navigation phase across status snapshots', () => {
|
||||
const sensitive = event('crypto', 1, 'trace-login', {
|
||||
kind: 'crypto',
|
||||
operation: 'AES.encrypt',
|
||||
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' },
|
||||
sensitiveCaptured: true,
|
||||
inputPreview: 'plain-value',
|
||||
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 11, preview: 'plain-value' }],
|
||||
});
|
||||
const metadataOnly = {
|
||||
...sensitive,
|
||||
sensitiveCaptured: false,
|
||||
inputPreview: undefined,
|
||||
inputs: sensitive.inputs.map(({ preview: _preview, ...item }) => item),
|
||||
};
|
||||
const completedNavigation = event('navigation', 2, 'trace-login', {
|
||||
kind: 'navigation',
|
||||
operation: 'navigation.document',
|
||||
durationMs: 48,
|
||||
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
||||
});
|
||||
const pageNavigation = {
|
||||
...completedNavigation,
|
||||
durationMs: undefined,
|
||||
navigation: { ...completedNavigation.navigation!, phase: 'started' as const },
|
||||
};
|
||||
|
||||
const merged = mergeRecordingEvents([[sensitive, completedNavigation], [metadataOnly, pageNavigation]]);
|
||||
expect(merged[0]).toMatchObject({ sensitiveCaptured: true, inputPreview: 'plain-value' });
|
||||
expect(merged[0].inputs[0].preview).toBe('plain-value');
|
||||
expect(merged[1].navigation?.phase).toBe('completed');
|
||||
expect(merged[1].durationMs).toBe(48);
|
||||
});
|
||||
|
||||
it('keeps exact value links separate from chronological navigation edges', () => {
|
||||
const crypto = event('crypto', 1, 'trace-login', {
|
||||
kind: 'crypto',
|
||||
operation: 'AES.encrypt',
|
||||
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' },
|
||||
outputs: [{ path: '$output', fingerprint: 'cipher', encoding: 'text', byteLength: 32 }],
|
||||
});
|
||||
const request = event('request', 2, 'trace-login', {
|
||||
kind: 'fetch',
|
||||
operation: 'request',
|
||||
inputs: [{ path: '$body.encryptedData', fingerprint: 'cipher', encoding: 'text', byteLength: 32 }],
|
||||
});
|
||||
const navigation = event('navigation', 3, 'trace-login', {
|
||||
kind: 'navigation',
|
||||
operation: 'navigation.document',
|
||||
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
||||
});
|
||||
|
||||
const links = buildRecordingLinks([crypto, request, navigation]);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toMatchObject({ fromEventId: 'crypto', toEventId: 'request' });
|
||||
});
|
||||
|
||||
it('correlates delayed Worker replies with the originating channel without claiming value equality', () => {
|
||||
const send = event('send', 1, 'trace-worker', {
|
||||
kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: 'worker-1',
|
||||
});
|
||||
const receive = event('receive', 2, 'trace-worker', {
|
||||
kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: 'worker-1',
|
||||
});
|
||||
const links = buildRecordingLinks([send, receive]);
|
||||
|
||||
expect(links).toEqual([expect.objectContaining({
|
||||
kind: 'channel', confidence: 'correlated', fromEventId: 'send', toEventId: 'receive',
|
||||
})]);
|
||||
expect(buildRecordingTraces([send, receive], links)[0]).toMatchObject({ messageCount: 2, linkedValueCount: 0 });
|
||||
});
|
||||
|
||||
it('links ordered constructor and session stages without claiming value equality', () => {
|
||||
const state = (phase: 'create' | 'update' | 'final') => ({
|
||||
adapterId: 'jsrsasign',
|
||||
providerKind: 'library' as const,
|
||||
family: 'signature' as const,
|
||||
operation: phase === 'final' ? 'sign' : phase,
|
||||
state: { model: 'session' as const, correlationId: 'signature-session-1', phase },
|
||||
});
|
||||
const create = event('create', 1, 'trace-sign', {
|
||||
kind: 'crypto', operation: 'Signature.create', crypto: state('create'),
|
||||
});
|
||||
const update = event('update', 2, 'trace-sign', {
|
||||
kind: 'crypto', operation: 'Signature.updateString', crypto: state('update'),
|
||||
});
|
||||
const final = event('final', 3, 'trace-sign', {
|
||||
kind: 'crypto', operation: 'Signature.sign', crypto: state('final'),
|
||||
outputs: [{ path: '$output', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const request = event('request', 4, 'trace-sign', {
|
||||
kind: 'fetch', operation: 'request',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
|
||||
const links = buildRecordingLinks([create, update, final, request]);
|
||||
expect(links.filter((link) => link.kind === 'state')).toEqual([
|
||||
expect.objectContaining({ fromEventId: 'create', toEventId: 'update', confidence: 'correlated' }),
|
||||
expect.objectContaining({ fromEventId: 'update', toEventId: 'final', confidence: 'correlated' }),
|
||||
]);
|
||||
expect(links.filter((link) => link.kind === 'value')).toEqual([
|
||||
expect.objectContaining({ fromEventId: 'final', toEventId: 'request', confidence: 'exact' }),
|
||||
]);
|
||||
expect(buildRecordingTraces([create, update, final, request], links)[0].linkedValueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingLink,
|
||||
BrowserRecordingTrace,
|
||||
BrowserRecordingValueEvidence,
|
||||
} from '@/types/models';
|
||||
import { cryptoEventLabel } from '@/features/browser-crypto/model';
|
||||
|
||||
export const MAX_RECORDING_EVENTS = 500;
|
||||
|
||||
const NAVIGATION_PHASE_ORDER: Record<NonNullable<BrowserRecordingEvent['navigation']>['phase'], number> = {
|
||||
started: 0,
|
||||
committed: 1,
|
||||
completed: 2,
|
||||
restored: 2,
|
||||
'same-document': 2,
|
||||
failed: 2,
|
||||
};
|
||||
|
||||
function mergeEvidence(
|
||||
previous: BrowserRecordingValueEvidence[],
|
||||
next: BrowserRecordingValueEvidence[],
|
||||
): BrowserRecordingValueEvidence[] {
|
||||
const previousByIdentity = new Map(previous.map((item) => [`${item.path}\u0000${item.fingerprint}`, item]));
|
||||
return next.map((item) => {
|
||||
const existing = previousByIdentity.get(`${item.path}\u0000${item.fingerprint}`);
|
||||
return existing && item.preview === undefined ? { ...item, preview: existing.preview } : item;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeEvent(previous: BrowserRecordingEvent, next: BrowserRecordingEvent): BrowserRecordingEvent {
|
||||
const previousNavigation = previous.navigation;
|
||||
const nextNavigation = next.navigation;
|
||||
const navigation = previousNavigation && nextNavigation
|
||||
? NAVIGATION_PHASE_ORDER[previousNavigation.phase] > NAVIGATION_PHASE_ORDER[nextNavigation.phase]
|
||||
? previousNavigation
|
||||
: nextNavigation
|
||||
: nextNavigation || previousNavigation;
|
||||
return {
|
||||
...previous,
|
||||
...next,
|
||||
durationMs: next.durationMs ?? previous.durationMs,
|
||||
sensitiveCaptured: previous.sensitiveCaptured || next.sensitiveCaptured,
|
||||
inputPreview: next.inputPreview ?? previous.inputPreview,
|
||||
outputPreview: next.outputPreview ?? previous.outputPreview,
|
||||
inputs: mergeEvidence(previous.inputs, next.inputs),
|
||||
outputs: mergeEvidence(previous.outputs, next.outputs),
|
||||
navigation,
|
||||
error: next.error ?? previous.error,
|
||||
};
|
||||
}
|
||||
|
||||
function orderedEvents(events: BrowserRecordingEvent[]): BrowserRecordingEvent[] {
|
||||
return [...events].sort((left, right) => (
|
||||
left.sequence - right.sequence
|
||||
|| left.timestamp - right.timestamp
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
export function mergeRecordingEvents(
|
||||
collections: BrowserRecordingEvent[][],
|
||||
limit = MAX_RECORDING_EVENTS,
|
||||
): BrowserRecordingEvent[] {
|
||||
const byId = new Map<string, BrowserRecordingEvent>();
|
||||
for (const events of collections) {
|
||||
for (const event of events) {
|
||||
const previous = byId.get(event.id);
|
||||
byId.set(event.id, previous ? mergeEvent(previous, event) : event);
|
||||
}
|
||||
}
|
||||
return orderedEvents([...byId.values()]).slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
export function nextRecordingSequence(events: BrowserRecordingEvent[]): number {
|
||||
return events.reduce((maximum, event) => Math.max(maximum, event.sequence), 0) + 1;
|
||||
}
|
||||
|
||||
export function latestRecordingTraceId(events: BrowserRecordingEvent[]): string | undefined {
|
||||
return orderedEvents(events).at(-1)?.traceId;
|
||||
}
|
||||
|
||||
export function buildRecordingLinks(events: BrowserRecordingEvent[]): BrowserRecordingLink[] {
|
||||
const links: BrowserRecordingLink[] = [];
|
||||
const outputs = new Map<string, Array<{ eventId: string; path: string; traceId: string }>>();
|
||||
for (const event of orderedEvents(events)) {
|
||||
for (const input of event.inputs) {
|
||||
const candidates = outputs.get(input.fingerprint) || [];
|
||||
const source = [...candidates].reverse().find((candidate) => (
|
||||
candidate.traceId === event.traceId && candidate.eventId !== event.id
|
||||
));
|
||||
if (!source) continue;
|
||||
links.push({
|
||||
id: `link-${source.eventId}-${event.id}-${links.length}`,
|
||||
traceId: event.traceId,
|
||||
kind: 'value',
|
||||
fromEventId: source.eventId,
|
||||
fromPath: source.path,
|
||||
toEventId: event.id,
|
||||
toPath: input.path,
|
||||
confidence: 'exact',
|
||||
});
|
||||
}
|
||||
for (const output of event.outputs) {
|
||||
const current = outputs.get(output.fingerprint) || [];
|
||||
current.push({ eventId: event.id, path: output.path, traceId: event.traceId });
|
||||
outputs.set(output.fingerprint, current.slice(-32));
|
||||
}
|
||||
}
|
||||
const lastStateEvent = new Map<string, BrowserRecordingEvent>();
|
||||
for (const event of orderedEvents(events)) {
|
||||
const state = event.crypto?.state;
|
||||
if (event.kind !== 'crypto' || !state?.correlationId || state.model === 'stateless') continue;
|
||||
const key = `${event.traceId}\u0000${event.crypto?.adapterId || ''}\u0000${state.correlationId}`;
|
||||
const source = lastStateEvent.get(key);
|
||||
if (source && source.id !== event.id) {
|
||||
links.push({
|
||||
id: `link-state-${source.id}-${event.id}-${links.length}`,
|
||||
traceId: event.traceId,
|
||||
kind: 'state',
|
||||
fromEventId: source.id,
|
||||
fromPath: `$state.${source.crypto?.state?.phase || 'unknown'}`,
|
||||
toEventId: event.id,
|
||||
toPath: `$state.${state.phase || 'unknown'}`,
|
||||
confidence: 'correlated',
|
||||
});
|
||||
}
|
||||
lastStateEvent.set(key, event);
|
||||
}
|
||||
const lastSentByChannel = new Map<string, BrowserRecordingEvent>();
|
||||
for (const event of orderedEvents(events)) {
|
||||
if (!event.channelId || !['worker', 'message'].includes(event.kind)) continue;
|
||||
if (event.direction === 'send') {
|
||||
lastSentByChannel.set(event.channelId, event);
|
||||
continue;
|
||||
}
|
||||
if (event.direction !== 'receive') continue;
|
||||
const source = lastSentByChannel.get(event.channelId);
|
||||
if (!source || source.traceId !== event.traceId) continue;
|
||||
links.push({
|
||||
id: `link-channel-${source.id}-${event.id}-${links.length}`,
|
||||
traceId: event.traceId,
|
||||
kind: 'channel',
|
||||
fromEventId: source.id,
|
||||
fromPath: '$message',
|
||||
toEventId: event.id,
|
||||
toPath: '$message',
|
||||
confidence: 'correlated',
|
||||
});
|
||||
}
|
||||
return links.slice(0, 1_000);
|
||||
}
|
||||
|
||||
function requestPath(url: string): string {
|
||||
try { return new URL(url, 'https://recording.invalid').pathname; } catch { return url; }
|
||||
}
|
||||
|
||||
function traceLabel(events: BrowserRecordingEvent[]): string {
|
||||
const interaction = events.find((item) => item.kind === 'interaction');
|
||||
if (interaction?.label) return interaction.label;
|
||||
const request = events.find((item) => ['fetch', 'xhr', 'form', 'beacon'].includes(item.kind));
|
||||
if (request?.url) return `${request.method || 'GET'} ${requestPath(request.url)}`;
|
||||
const crypto = events.find((item) => item.kind === 'crypto');
|
||||
if (crypto) return cryptoEventLabel(crypto);
|
||||
const message = events.find((item) => item.kind === 'worker' || item.kind === 'message');
|
||||
if (message) return message.kind === 'worker' ? 'Worker 消息' : '页面消息通道';
|
||||
const navigation = events.find((item) => item.kind === 'navigation');
|
||||
if (navigation?.navigation?.kind === 'back-forward') return '浏览器前进或后退';
|
||||
if (navigation?.navigation?.kind === 'history') return '页面路由变化';
|
||||
if (navigation?.navigation?.kind === 'fragment') return '页面锚点变化';
|
||||
return navigation?.label || '页面后台活动';
|
||||
}
|
||||
|
||||
export function buildRecordingTraces(
|
||||
events: BrowserRecordingEvent[],
|
||||
links: BrowserRecordingLink[],
|
||||
): BrowserRecordingTrace[] {
|
||||
const groups = new Map<string, BrowserRecordingEvent[]>();
|
||||
for (const event of events) groups.set(event.traceId, [...(groups.get(event.traceId) || []), event]);
|
||||
return [...groups.entries()].map(([id, traceEvents]) => {
|
||||
const sorted = orderedEvents(traceEvents);
|
||||
return {
|
||||
id,
|
||||
interactionId: sorted.find((item) => item.interactionId)?.interactionId,
|
||||
label: traceLabel(sorted),
|
||||
startedAt: sorted[0]?.timestamp || 0,
|
||||
endedAt: sorted.reduce((maximum, item) => Math.max(maximum, item.timestamp + (item.durationMs || 0)), sorted[0]?.timestamp || 0),
|
||||
eventIds: sorted.map((item) => item.id),
|
||||
requestCount: sorted.filter((item) => ['fetch', 'xhr', 'form', 'beacon'].includes(item.kind)).length,
|
||||
cryptoCount: sorted.filter((item) => item.kind === 'crypto').length,
|
||||
websocketCount: sorted.filter((item) => item.kind === 'websocket').length,
|
||||
messageCount: sorted.filter((item) => item.kind === 'worker' || item.kind === 'message').length,
|
||||
navigationCount: sorted.filter((item) => item.kind === 'navigation').length,
|
||||
linkedValueCount: links.filter((item) => item.traceId === id && item.confidence === 'exact').length,
|
||||
};
|
||||
}).sort((left, right) => left.startedAt - right.startedAt || left.id.localeCompare(right.id));
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowDown, ArrowRight, Braces, CheckCircle2, ChevronDown, CirclePlus, Code2,
|
||||
FileInput, FileKey2, FlaskConical, Link2, Play, Plus, RefreshCw, Save, Sparkles, Trash2, Unplug,
|
||||
} 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, BrowserRecordingEvent, BrowserTransformBuiltinOperation,
|
||||
BrowserTransformDirection, BrowserTransformDirectionName, BrowserTransformExecution,
|
||||
BrowserTransformNodeReference, BrowserTransformPipelineNode, BrowserTransformProfile,
|
||||
BrowserTransformProfileInput, BrowserProfileInferenceCandidate,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
compileGuidedTransform, defaultGuidedTransform, guidedOutputDescription, parseGuidedTransform,
|
||||
type GuidedTransformDraft, type GuidedTransformOutputKind,
|
||||
} from './guided';
|
||||
import { createBrowserTransformProfileInput } from './profile-draft';
|
||||
import {
|
||||
clearBrowserTransformReplayDraft,
|
||||
deleteBrowserTransformReplayDrafts,
|
||||
getBrowserTransformReplayDraft,
|
||||
saveBrowserTransformReplayDraft,
|
||||
type BrowserTransformReplayDraftFields,
|
||||
type BrowserTransformReplayDraftInput,
|
||||
} from './replay-draft';
|
||||
import './browser-transform-workspace.css';
|
||||
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface BrowserTransformWorkspaceProps {
|
||||
tab?: ActiveTabInfo;
|
||||
selectedEvent?: BrowserRecordingEvent;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
onOpenCapture: () => void;
|
||||
suggestion?: BrowserTransformSuggestionSeed;
|
||||
}
|
||||
|
||||
export interface BrowserTransformSuggestionSeed {
|
||||
revision: number;
|
||||
candidate: BrowserProfileInferenceCandidate;
|
||||
callable: BrowserPageCallable;
|
||||
profile: BrowserTransformProfile;
|
||||
sampleBody?: string;
|
||||
sampleLabel?: string;
|
||||
}
|
||||
|
||||
const BUILTINS: Array<{ value: BrowserTransformBuiltinOperation; label: string }> = [
|
||||
{ value: 'value.literal', label: '固定值' },
|
||||
{ value: 'json.stringify', label: 'JSON 序列化' },
|
||||
{ value: 'json.parse', label: 'JSON 解析' },
|
||||
{ value: 'text.toString', label: '转为文本' },
|
||||
{ value: 'url.encode', label: 'URL 编码' },
|
||||
{ value: 'url.decode', label: 'URL 解码' },
|
||||
{ value: 'base64.encode', label: 'Base64 编码' },
|
||||
{ value: 'base64.decode', label: 'Base64 解码' },
|
||||
{ value: 'hex.encode', label: 'Hex 编码' },
|
||||
{ value: 'hex.decode', label: 'Hex 解码' },
|
||||
{ value: 'object.pick', label: '选择对象字段' },
|
||||
{ value: 'object.compose', label: '组合对象' },
|
||||
{ value: 'form.compose', label: '组合表单' },
|
||||
];
|
||||
|
||||
function originOf(url?: string): string {
|
||||
try { return url ? new URL(url).origin : ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function absoluteUrl(value?: string, base?: string): string {
|
||||
try { return value ? new URL(value, base).toString() : base || ''; } catch { return value || base || ''; }
|
||||
}
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
|
||||
type GuidedInputKind = 'body' | 'body-field' | 'text' | 'header-field' | 'query-field' | 'custom';
|
||||
|
||||
function splitInputPath(path: string): { kind: GuidedInputKind; field: string } {
|
||||
if (path === 'body') return { kind: 'body', field: '' };
|
||||
if (path === 'text') return { kind: 'text', field: '' };
|
||||
if (path.startsWith('body.')) return { kind: 'body-field', field: path.slice(5) };
|
||||
if (path.startsWith('headers.')) return { kind: 'header-field', field: path.slice(8) };
|
||||
if (path.startsWith('query.')) return { kind: 'query-field', field: path.slice(6) };
|
||||
return { kind: 'custom', field: path };
|
||||
}
|
||||
|
||||
function joinInputPath(kind: GuidedInputKind, field: string): string {
|
||||
if (kind === 'body') return 'body';
|
||||
if (kind === 'text') return 'text';
|
||||
if (kind === 'body-field') return `body.${field.trim()}`;
|
||||
if (kind === 'header-field') return `headers.${field.trim().toLowerCase()}`;
|
||||
if (kind === 'query-field') return `query.${field.trim()}`;
|
||||
return field.trim();
|
||||
}
|
||||
|
||||
function outputFieldLabel(kind: GuidedTransformOutputKind): string {
|
||||
if (kind === 'json-field') return 'JSON 字段名';
|
||||
if (kind === 'form-field') return '表单字段名';
|
||||
if (kind === 'header') return 'Header 名称';
|
||||
if (kind === 'query') return 'Query 参数名';
|
||||
return '';
|
||||
}
|
||||
|
||||
const INPUT_ROLE_LABELS: Record<BrowserPageCallable['inputSlots'][number]['role'], string> = {
|
||||
data: '明文数据',
|
||||
key: '密钥',
|
||||
iv: 'IV',
|
||||
algorithm: '算法',
|
||||
options: '选项',
|
||||
signature: '签名',
|
||||
salt: 'Salt',
|
||||
nonce: 'Nonce',
|
||||
aad: '附加数据',
|
||||
unknown: '页面参数',
|
||||
};
|
||||
|
||||
function toInput(profile: BrowserTransformProfile): BrowserTransformProfileInput {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
enabled: profile.enabled,
|
||||
target: { ...profile.target },
|
||||
origin: profile.origin,
|
||||
match: { methods: [...profile.match.methods], urlPattern: profile.match.urlPattern },
|
||||
request: structuredClone(profile.request),
|
||||
response: structuredClone(profile.response),
|
||||
failMode: 'closed',
|
||||
maxConcurrency: profile.maxConcurrency,
|
||||
};
|
||||
}
|
||||
|
||||
function profileFingerprint(profile?: BrowserTransformProfileInput): string {
|
||||
return profile ? JSON.stringify(profile) : '';
|
||||
}
|
||||
|
||||
function encodeUtf8(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function formatSampleBody(value: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed, null, 2) : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleHeaders(value: string): string {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return '{"Content-Type":"application/json"}';
|
||||
} catch {
|
||||
return '{"Content-Type":"text/plain; charset=utf-8"}';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_BODY = '{\n "value": "plain"\n}';
|
||||
|
||||
type ReplayPersistenceState = 'memory' | 'loading' | 'ready' | 'saving' | 'saved' | 'too-large' | 'error';
|
||||
|
||||
interface PendingReplaySave {
|
||||
key: string;
|
||||
fingerprint: string;
|
||||
input: BrowserTransformReplayDraftInput;
|
||||
revision: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
function defaultReplayFields(tab?: ActiveTabInfo, selectedEvent?: BrowserRecordingEvent): BrowserTransformReplayDraftFields {
|
||||
return {
|
||||
method: selectedEvent?.method || 'POST',
|
||||
url: absoluteUrl(selectedEvent?.url, tab?.url),
|
||||
headers: '{"Content-Type":"application/json"}',
|
||||
body: DEFAULT_REPLAY_BODY,
|
||||
};
|
||||
}
|
||||
|
||||
function replayFieldsFingerprint(fields: BrowserTransformReplayDraftFields): string {
|
||||
return JSON.stringify(fields);
|
||||
}
|
||||
|
||||
function replayPersistenceLabel(state: ReplayPersistenceState): string {
|
||||
if (state === 'memory') return '保存网关后自动保存';
|
||||
if (state === 'loading') return '正在恢复';
|
||||
if (state === 'ready') return '本机自动保存';
|
||||
if (state === 'saving') return '正在保存';
|
||||
if (state === 'saved') return '本机已保存';
|
||||
if (state === 'too-large') return '样本过大';
|
||||
return '保存失败';
|
||||
}
|
||||
|
||||
function nodeLabel(kind: BrowserTransformPipelineNode['kind']): string {
|
||||
if (kind === 'context.read') return '上下文';
|
||||
if (kind === 'builtin') return '内置转换';
|
||||
if (kind === 'page.call') return '页面函数';
|
||||
return '输出';
|
||||
}
|
||||
|
||||
function callableKindLabel(callable: BrowserPageCallable): string {
|
||||
if (callable.kind === 'recorded-call') return '录制调用';
|
||||
if (callable.kind === 'business-closure') return '业务闭包';
|
||||
if (callable.kind === 'request-transaction') return '请求事务';
|
||||
return '全局函数';
|
||||
}
|
||||
|
||||
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
|
||||
if (node.kind === 'builtin') return node.inputs;
|
||||
if (node.kind === 'page.call') return node.arguments;
|
||||
if (node.kind === 'output.write') return [node.source];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function BrowserTransformWorkspace({ tab, selectedEvent, busy, run, onOpenCapture, suggestion }: BrowserTransformWorkspaceProps) {
|
||||
const [profiles, setProfiles] = useState<BrowserTransformProfile[]>([]);
|
||||
const [callables, setCallables] = useState<BrowserPageCallable[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
const [draft, setDraft] = useState<BrowserTransformProfileInput>();
|
||||
const [directionName, setDirectionName] = useState<BrowserTransformDirectionName>('request');
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [testMethod, setTestMethod] = useState('POST');
|
||||
const [testUrl, setTestUrl] = useState('');
|
||||
const [testHeaders, setTestHeaders] = useState('{"Content-Type":"application/json"}');
|
||||
const [testBody, setTestBody] = useState(DEFAULT_REPLAY_BODY);
|
||||
const [testSample, setTestSample] = useState<{ body: string; label: string }>();
|
||||
const [testResult, setTestResult] = useState<BrowserTransformExecution>();
|
||||
const [testError, setTestError] = useState('');
|
||||
const [replayPersistence, setReplayPersistence] = useState<ReplayPersistenceState>('memory');
|
||||
const [replayStorageError, setReplayStorageError] = useState('');
|
||||
const [replayLoadedKey, setReplayLoadedKey] = useState('');
|
||||
const [editorMode, setEditorMode] = useState<'guided' | 'advanced'>('guided');
|
||||
const [confirmDeleteCallableId, setConfirmDeleteCallableId] = useState('');
|
||||
const handledSuggestion = useRef(0);
|
||||
const replayLoadRevision = useRef(0);
|
||||
const replaySaveRevision = useRef(0);
|
||||
const replayBaselineFingerprint = useRef('');
|
||||
const replayStablePersistence = useRef<ReplayPersistenceState>('memory');
|
||||
const pendingReplaySeed = useRef<{ key: string; fields: BrowserTransformReplayDraftFields } | undefined>(undefined);
|
||||
const pendingReplaySave = useRef<PendingReplaySave | undefined>(undefined);
|
||||
|
||||
const replayProfileId = draft?.id || '';
|
||||
const replayOrigin = draft?.origin || '';
|
||||
const replayKey = replayProfileId ? `${replayProfileId}:${directionName}` : '';
|
||||
const replayActiveKey = useRef(replayKey);
|
||||
replayActiveKey.current = replayKey;
|
||||
const workspaceMounted = useRef(false);
|
||||
const replayFields = useMemo<BrowserTransformReplayDraftFields>(() => ({
|
||||
method: testMethod,
|
||||
url: testUrl,
|
||||
headers: testHeaders,
|
||||
body: testBody,
|
||||
sample: testSample,
|
||||
}), [testBody, testHeaders, testMethod, testSample, testUrl]);
|
||||
const replayFingerprint = useMemo(() => replayFieldsFingerprint(replayFields), [replayFields]);
|
||||
|
||||
const applyReplayFields = useCallback((fields: BrowserTransformReplayDraftFields) => {
|
||||
setTestMethod(fields.method);
|
||||
setTestUrl(fields.url);
|
||||
setTestHeaders(fields.headers);
|
||||
setTestBody(fields.body);
|
||||
setTestSample(fields.sample);
|
||||
setTestResult(undefined);
|
||||
setTestError('');
|
||||
}, []);
|
||||
|
||||
const discardPendingReplaySave = useCallback(() => {
|
||||
replaySaveRevision.current += 1;
|
||||
if (pendingReplaySave.current) clearTimeout(pendingReplaySave.current.timeout);
|
||||
pendingReplaySave.current = undefined;
|
||||
}, []);
|
||||
|
||||
const flushPendingReplaySave = useCallback(() => {
|
||||
const pending = pendingReplaySave.current;
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timeout);
|
||||
pendingReplaySave.current = undefined;
|
||||
replaySaveRevision.current += 1;
|
||||
void saveBrowserTransformReplayDraft(pending.input).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const target = tab ? { tabId: tab.id, frameId: 0 } : undefined;
|
||||
const [nextProfiles, nextCallables] = await Promise.all([
|
||||
request('transform.profile.list', target || {}),
|
||||
target ? request('callable.list', target).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setProfiles(nextProfiles);
|
||||
setCallables(nextCallables);
|
||||
setLoadError('');
|
||||
setSelectedProfileId((current) => nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id || '');
|
||||
} catch (error) {
|
||||
setLoadError(errorMessage(error));
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
workspaceMounted.current = true;
|
||||
return () => { workspaceMounted.current = false; };
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => {
|
||||
const selected = profiles.find((profile) => profile.id === selectedProfileId);
|
||||
if (selected) {
|
||||
setDraft(toInput(selected));
|
||||
const selectedDirection = selected.request.enabled ? selected.request : selected.response;
|
||||
setEditorMode(parseGuidedTransform(selectedDirection, callables) ? 'guided' : 'advanced');
|
||||
}
|
||||
}, [callables, profiles, selectedProfileId]);
|
||||
useEffect(() => {
|
||||
const revision = ++replayLoadRevision.current;
|
||||
discardPendingReplaySave();
|
||||
setReplayLoadedKey('');
|
||||
setReplayStorageError('');
|
||||
const fallback = defaultReplayFields(tab, selectedEvent);
|
||||
if (!replayProfileId) {
|
||||
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
||||
replayStablePersistence.current = 'memory';
|
||||
applyReplayFields(fallback);
|
||||
setReplayPersistence('memory');
|
||||
return;
|
||||
}
|
||||
const seed = pendingReplaySeed.current?.key === replayKey ? pendingReplaySeed.current.fields : undefined;
|
||||
if (seed) {
|
||||
pendingReplaySeed.current = undefined;
|
||||
replayBaselineFingerprint.current = '';
|
||||
replayStablePersistence.current = 'ready';
|
||||
applyReplayFields(seed);
|
||||
setReplayPersistence('ready');
|
||||
setReplayLoadedKey(replayKey);
|
||||
return;
|
||||
}
|
||||
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
||||
replayStablePersistence.current = 'ready';
|
||||
applyReplayFields(fallback);
|
||||
setReplayPersistence('loading');
|
||||
void getBrowserTransformReplayDraft(replayProfileId, directionName, replayOrigin)
|
||||
.then((stored) => {
|
||||
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
|
||||
const fields = stored ? {
|
||||
method: stored.method,
|
||||
url: stored.url,
|
||||
headers: stored.headers,
|
||||
body: stored.body,
|
||||
sample: stored.sample,
|
||||
} : fallback;
|
||||
replayBaselineFingerprint.current = replayFieldsFingerprint(fields);
|
||||
replayStablePersistence.current = stored ? 'saved' : 'ready';
|
||||
applyReplayFields(fields);
|
||||
setReplayPersistence(stored ? 'saved' : 'ready');
|
||||
setReplayLoadedKey(replayKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
|
||||
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
||||
replayStablePersistence.current = 'error';
|
||||
setReplayStorageError(`无法恢复本机回放草稿:${errorMessage(error)}`);
|
||||
setReplayPersistence('error');
|
||||
setReplayLoadedKey(replayKey);
|
||||
});
|
||||
return () => { replayLoadRevision.current += 1; };
|
||||
// Replay defaults are captured only when the profile/direction changes.
|
||||
// Navigation and new recording selections must not overwrite an edited draft.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [replayKey]);
|
||||
useEffect(() => () => flushPendingReplaySave(), [flushPendingReplaySave, replayKey]);
|
||||
useEffect(() => {
|
||||
if (!replayProfileId || replayLoadedKey !== replayKey) return;
|
||||
if (replayFingerprint === replayBaselineFingerprint.current) {
|
||||
pendingReplaySave.current = undefined;
|
||||
setReplayPersistence(replayStablePersistence.current);
|
||||
return;
|
||||
}
|
||||
const revision = ++replaySaveRevision.current;
|
||||
setReplayPersistence('saving');
|
||||
setReplayStorageError('');
|
||||
const input: BrowserTransformReplayDraftInput = {
|
||||
...structuredClone(replayFields),
|
||||
profileId: replayProfileId,
|
||||
direction: directionName,
|
||||
origin: replayOrigin,
|
||||
};
|
||||
const pending: PendingReplaySave = {
|
||||
key: replayKey,
|
||||
fingerprint: replayFingerprint,
|
||||
input,
|
||||
revision,
|
||||
timeout: setTimeout(() => {
|
||||
if (pendingReplaySave.current === pending) pendingReplaySave.current = undefined;
|
||||
void saveBrowserTransformReplayDraft(input)
|
||||
.then((result) => {
|
||||
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
|
||||
replayBaselineFingerprint.current = pending.fingerprint;
|
||||
replayStablePersistence.current = result.status === 'saved' ? 'saved' : 'too-large';
|
||||
setReplayPersistence(replayStablePersistence.current);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
|
||||
setReplayStorageError(`无法保存本机回放草稿:${errorMessage(error)}`);
|
||||
setReplayPersistence('error');
|
||||
});
|
||||
}, 350),
|
||||
};
|
||||
pendingReplaySave.current = pending;
|
||||
return () => clearTimeout(pending.timeout);
|
||||
}, [directionName, replayFields, replayFingerprint, replayKey, replayLoadedKey, replayOrigin, replayProfileId]);
|
||||
useEffect(() => {
|
||||
if (!suggestion || !tab || handledSuggestion.current >= suggestion.revision) return;
|
||||
handledSuggestion.current = suggestion.revision;
|
||||
replayLoadRevision.current += 1;
|
||||
discardPendingReplaySave();
|
||||
const sampleBody = suggestion.sampleBody ? formatSampleBody(suggestion.sampleBody) : undefined;
|
||||
const fields: BrowserTransformReplayDraftFields = {
|
||||
...defaultReplayFields(tab, selectedEvent),
|
||||
method: suggestion.candidate.request.method || 'POST',
|
||||
url: absoluteUrl(suggestion.candidate.request.url, tab.url),
|
||||
headers: suggestion.sampleBody ? sampleHeaders(suggestion.sampleBody) : '{"Content-Type":"application/json"}',
|
||||
body: sampleBody || DEFAULT_REPLAY_BODY,
|
||||
sample: sampleBody ? { body: sampleBody, label: suggestion.sampleLabel || '录制短时样本' } : undefined,
|
||||
};
|
||||
const seedKey = `${suggestion.profile.id}:request`;
|
||||
pendingReplaySeed.current = { key: seedKey, fields };
|
||||
replayBaselineFingerprint.current = '';
|
||||
replayStablePersistence.current = 'ready';
|
||||
applyReplayFields(fields);
|
||||
setReplayLoadedKey(seedKey);
|
||||
setReplayPersistence('ready');
|
||||
setReplayStorageError('');
|
||||
setCallables((current) => [...current.filter((item) => item.id !== suggestion.callable.id), suggestion.callable]);
|
||||
setProfiles((current) => [suggestion.profile, ...current.filter((item) => item.id !== suggestion.profile.id)]);
|
||||
setSelectedProfileId(suggestion.profile.id);
|
||||
setDraft(toInput(suggestion.profile));
|
||||
setDirectionName('request');
|
||||
setEditorMode('guided');
|
||||
setTestResult(undefined);
|
||||
setTestError('');
|
||||
}, [applyReplayFields, discardPendingReplaySave, selectedEvent, suggestion, tab]);
|
||||
|
||||
const savedProfile = profiles.find((profile) => profile.id === selectedProfileId);
|
||||
const dirty = Boolean(draft && profileFingerprint(draft) !== profileFingerprint(savedProfile ? toInput(savedProfile) : undefined));
|
||||
const callableIds = useMemo(() => new Set(callables.map((callable) => callable.id)), [callables]);
|
||||
const referencedCallableIds = useMemo(() => draft ? [draft.request, draft.response]
|
||||
.flatMap((direction) => direction.enabled ? direction.nodes : [])
|
||||
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
|
||||
.map((node) => node.callableId) : [], [draft]);
|
||||
const bindingReady = Boolean(draft && originOf(tab?.url) === draft.origin && referencedCallableIds.every((id) => callableIds.has(id)));
|
||||
const callableReferences = useMemo(() => {
|
||||
const references = new Map<string, number>();
|
||||
for (const profile of profiles) {
|
||||
for (const node of [profile.request, profile.response].flatMap((item) => item.enabled ? item.nodes : [])) {
|
||||
if (node.kind !== 'page.call') continue;
|
||||
references.set(node.callableId, (references.get(node.callableId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}, [profiles]);
|
||||
const direction = draft?.[directionName];
|
||||
const guide = useMemo(() => direction ? parseGuidedTransform(direction, callables) : undefined, [callables, direction]);
|
||||
const guidedCallable = callables.find((callable) => callable.id === guide?.callableId);
|
||||
const guidedValid = Boolean(guide && guide.callableId
|
||||
&& guide.inputPaths.every((path) => path.trim())
|
||||
&& (guide.outputKind === 'body' || guide.outputField.trim()));
|
||||
const replayLoading = replayPersistence === 'loading';
|
||||
const replayPersistenceTitle = replayStorageError
|
||||
|| (replayPersistence === 'too-large'
|
||||
? '当前草稿超过 256 KiB,仅保留在本次页面中;已移除旧的本机副本,避免下次恢复过期内容。'
|
||||
: replayPersistence === 'memory'
|
||||
? '保存明文网关后,回放输入会仅保存在当前浏览器中。'
|
||||
: '仅保存在当前浏览器,不会进入明文网关导出、Bridge、Yak 引擎或 AI 上下文。');
|
||||
|
||||
const selectProfile = (profile: BrowserTransformProfile) => {
|
||||
setSelectedProfileId(profile.id);
|
||||
setDraft(toInput(profile));
|
||||
setDirectionName(profile.request.enabled ? 'request' : 'response');
|
||||
setEditorMode(parseGuidedTransform(profile.request.enabled ? profile.request : profile.response, callables) ? 'guided' : 'advanced');
|
||||
setTestResult(undefined);
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
if (!tab) return;
|
||||
setSelectedProfileId('');
|
||||
setDraft(createBrowserTransformProfileInput(tab, selectedEvent, callables[0]));
|
||||
setDirectionName('request');
|
||||
setEditorMode('guided');
|
||||
setTestResult(undefined);
|
||||
};
|
||||
|
||||
const patchDirection = (patcher: (value: BrowserTransformDirection) => BrowserTransformDirection) => {
|
||||
setDraft((current) => current ? { ...current, [directionName]: patcher(current[directionName]) } : current);
|
||||
setTestResult(undefined);
|
||||
};
|
||||
|
||||
const patchNode = (id: string, patch: Partial<BrowserTransformPipelineNode>) => patchDirection((current) => ({
|
||||
...current,
|
||||
nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } as BrowserTransformPipelineNode : node),
|
||||
}));
|
||||
|
||||
const patchGuide = (next: GuidedTransformDraft) => {
|
||||
const callable = callables.find((item) => item.id === next.callableId);
|
||||
patchDirection(() => compileGuidedTransform(next, callable));
|
||||
};
|
||||
|
||||
const selectGuidedCallable = (callableId: string) => {
|
||||
if (!guide) return;
|
||||
const callable = callables.find((item) => item.id === callableId);
|
||||
const defaults = defaultGuidedTransform(callable, { outputKind: guide.outputKind, outputField: guide.outputField });
|
||||
patchGuide({
|
||||
...guide,
|
||||
callableId,
|
||||
inputPaths: defaults.inputPaths.map((path, index) => guide.inputPaths[index] || path),
|
||||
});
|
||||
};
|
||||
|
||||
const patchGuideInput = (index: number, path: string) => {
|
||||
if (!guide) return;
|
||||
patchGuide({ ...guide, inputPaths: guide.inputPaths.map((item, itemIndex) => itemIndex === index ? path : item) });
|
||||
};
|
||||
|
||||
const addNode = (kind: BrowserTransformPipelineNode['kind']) => patchDirection((current) => {
|
||||
const previous = current.nodes.at(-1);
|
||||
const reference = previous ? { nodeId: previous.id } : { nodeId: '' };
|
||||
let node: BrowserTransformPipelineNode;
|
||||
if (kind === 'context.read') node = { id: uid('context'), name: '读取上下文', kind, path: 'body' };
|
||||
else if (kind === 'builtin') node = { id: uid('builtin'), name: '转换数据', kind, operation: 'json.stringify', inputs: previous ? [reference] : [] };
|
||||
else if (kind === 'page.call') node = { id: uid('call'), name: callables[0]?.name || '调用页面函数', kind, callableId: callables[0]?.id || '', arguments: previous ? [reference] : [] };
|
||||
else node = { id: uid('output'), name: '写入输出', kind, destination: 'body', source: reference, encoding: 'auto' };
|
||||
return { ...current, nodes: [...current.nodes, node] };
|
||||
});
|
||||
|
||||
const patchReferences = (node: BrowserTransformPipelineNode, references: BrowserTransformNodeReference[]) => {
|
||||
if (node.kind === 'builtin') patchNode(node.id, { inputs: references });
|
||||
else if (node.kind === 'page.call') patchNode(node.id, { arguments: references });
|
||||
};
|
||||
|
||||
const save = () => run(async () => {
|
||||
if (!draft) return;
|
||||
const replaySeed = structuredClone(replayFields);
|
||||
const wasNew = !draft.id;
|
||||
const profile = await request('transform.profile.save', draft);
|
||||
if (wasNew) {
|
||||
const seedKey = `${profile.id}:${directionName}`;
|
||||
pendingReplaySeed.current = { key: seedKey, fields: replaySeed };
|
||||
replayBaselineFingerprint.current = '';
|
||||
replayStablePersistence.current = 'ready';
|
||||
setReplayLoadedKey(seedKey);
|
||||
setReplayPersistence('ready');
|
||||
}
|
||||
setProfiles((current) => [profile, ...current.filter((item) => item.id !== profile.id)]);
|
||||
setSelectedProfileId(profile.id);
|
||||
setDraft(toInput(profile));
|
||||
}, 'Pipeline v2 配置已保存');
|
||||
|
||||
const remove = () => run(async () => {
|
||||
if (!draft?.id) { setDraft(undefined); return; }
|
||||
const removedId = draft.id;
|
||||
discardPendingReplaySave();
|
||||
setReplayLoadedKey('');
|
||||
const remaining = await request('transform.profile.delete', { id: removedId });
|
||||
// The background service also removes these keys. Repeating the local
|
||||
// cleanup here serializes behind any Options-page write already in flight.
|
||||
await deleteBrowserTransformReplayDrafts(removedId).catch(() => undefined);
|
||||
setProfiles(remaining);
|
||||
setSelectedProfileId(remaining[0]?.id || '');
|
||||
setDraft(remaining[0] ? toInput(remaining[0]) : undefined);
|
||||
}, '明文网关配置已删除');
|
||||
|
||||
const clearReplay = () => run(async () => {
|
||||
if (!draft?.id) return;
|
||||
const profileId = draft.id;
|
||||
const key = replayKey;
|
||||
replayLoadRevision.current += 1;
|
||||
discardPendingReplaySave();
|
||||
setReplayLoadedKey('');
|
||||
await clearBrowserTransformReplayDraft(profileId, directionName);
|
||||
if (replayActiveKey.current !== key) return;
|
||||
const fallback = defaultReplayFields(tab, selectedEvent);
|
||||
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
|
||||
replayStablePersistence.current = 'ready';
|
||||
applyReplayFields(fallback);
|
||||
setReplayStorageError('');
|
||||
setReplayPersistence('ready');
|
||||
setReplayLoadedKey(key);
|
||||
}, '当前方向的本机回放草稿已清空');
|
||||
|
||||
const deleteCallable = (callable: BrowserPageCallable) => run(async () => {
|
||||
const remaining = await request('callable.delete', { ...callable.target, callableId: callable.id });
|
||||
setCallables(remaining);
|
||||
setConfirmDeleteCallableId('');
|
||||
setTestResult(undefined);
|
||||
}, callableReferences.get(callable.id) ? '页面函数已删除,引用它的明文网关需要重新绑定' : '页面函数已删除');
|
||||
|
||||
const execute = async () => {
|
||||
if (!draft?.id || dirty) { setTestError('请先保存当前 Pipeline'); return; }
|
||||
setTestError('');
|
||||
setTestResult(undefined);
|
||||
try {
|
||||
const parsed = JSON.parse(testHeaders) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Header 必须是 JSON 对象');
|
||||
const headers = Object.entries(parsed as Record<string, unknown>).map(([name, value]) => ({ name, value: String(value) }));
|
||||
setTestResult(await request('transform.execute', {
|
||||
profileId: draft.id,
|
||||
direction: directionName,
|
||||
packet: { method: testMethod.toUpperCase(), url: testUrl, statusCode: directionName === 'response' ? 200 : undefined, headers, bodyBase64: encodeUtf8(testBody) },
|
||||
}));
|
||||
} catch (error) { setTestError(errorMessage(error)); }
|
||||
};
|
||||
|
||||
return <div className="transform-workbench">
|
||||
<aside className="transform-profiles">
|
||||
<header><div><strong>明文网关</strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={create}><Plus size={15} /></Button></header>
|
||||
<div className="transform-profile-list">
|
||||
{profiles.map((profile) => {
|
||||
const ready = originOf(tab?.url) === profile.origin && [profile.request, profile.response]
|
||||
.flatMap((item) => item.enabled ? item.nodes : [])
|
||||
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
|
||||
.every((node) => callableIds.has(node.callableId));
|
||||
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => selectProfile(profile)}>
|
||||
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
|
||||
<span><strong>{profile.name}</strong><small>{profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
|
||||
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
|
||||
</button>;
|
||||
})}
|
||||
{!profiles.length && <div className="transform-profile-empty"><FileKey2 size={20} /><strong>没有 Pipeline</strong><Button size="sm" variant="primary" disabled={!tab} onClick={create}><CirclePlus size={14} />新建</Button></div>}
|
||||
</div>
|
||||
<footer>
|
||||
<details className="transform-callable-menu">
|
||||
<summary className={callables.length ? 'is-ready' : ''}><i />{callables.length} 个页面函数<ChevronDown size={12} /></summary>
|
||||
<div className="transform-callable-popover">
|
||||
<header><div><strong>当前文档页面函数</strong><span>页面刷新或导航后自动失效</span></div><em>{callables.length}</em></header>
|
||||
{!callables.length ? <div className="transform-callable-empty"><Code2 size={17} /><span>还没有可管理的页面函数</span></div> : <div className="transform-callable-list">{callables.map((callable) => {
|
||||
const referenceCount = callableReferences.get(callable.id) || 0;
|
||||
const confirming = confirmDeleteCallableId === callable.id;
|
||||
return <section key={callable.id}>
|
||||
<div className="transform-callable-row"><span><strong>{callable.name}</strong><small>{callableKindLabel(callable)} · {callable.algorithm || callable.operation}</small></span><Button size="icon" variant="ghost" aria-label={`删除 ${callable.name}`} title="删除页面函数" disabled={busy} onClick={() => setConfirmDeleteCallableId(callable.id)}><Trash2 size={13} /></Button></div>
|
||||
{confirming && <div className="transform-callable-confirm"><span>{referenceCount ? `${referenceCount} 个网关节点正在引用,删除后会显示“页面函数缺失”。` : '这个页面函数将从当前文档中移除。'}</span><div><Button size="sm" variant="ghost" onClick={() => setConfirmDeleteCallableId('')}>取消</Button><Button size="sm" variant="danger" disabled={busy} onClick={() => void deleteCallable(callable)}>确认删除</Button></div></div>}
|
||||
</section>;
|
||||
})}</div>}
|
||||
</div>
|
||||
</details>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新页面绑定" title="刷新页面绑定" onClick={() => void load()}><RefreshCw size={14} /></Button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<main className="transform-editor">
|
||||
{!draft ? <div className="transform-editor-empty"><Link2 size={24} /><strong>建立明文与线上报文的转换链路</strong>{callables.length ? <Button variant="primary" onClick={create}><CirclePlus size={14} />新建 Pipeline</Button> : <Button variant="primary" onClick={onOpenCapture}><Code2 size={14} />先捕获页面函数</Button>}</div> : <>
|
||||
<header className="transform-editor-head"><div><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /><span>{draft.origin} · document 生命周期</span></div><label><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} />启用</label></header>
|
||||
<div className="transform-route">
|
||||
<label><span>HTTP 方法</span><input value={draft.match.methods.join(', ')} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, methods: event.target.value.split(',').map((item) => item.trim().toUpperCase()).filter(Boolean) } })} /></label>
|
||||
<label><span>URL 模式</span><input value={draft.match.urlPattern} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, urlPattern: event.target.value } })} /></label>
|
||||
<label><span>并发</span><input type="number" min={1} max={8} value={draft.maxConcurrency} onChange={(event) => setDraft({ ...draft, maxConcurrency: Number(event.target.value) })} /></label>
|
||||
</div>
|
||||
<div className="transform-direction-tabs">
|
||||
{(['request', 'response'] as const).map((name) => <button key={name} className={directionName === name ? 'is-selected' : ''} onClick={() => setDirectionName(name)}>{name === 'request' ? '请求加密' : '响应解密'}<i className={draft[name].enabled ? 'is-enabled' : ''}>{draft[name].enabled ? `${draft[name].nodes.length} 节点` : '关闭'}</i></button>)}
|
||||
</div>
|
||||
{direction && <div className="transform-pipeline-editor">
|
||||
<div className="transform-direction-state"><div><strong>{directionName === 'request' ? '明文 → 线上请求' : '线上响应 → 明文'}</strong><span>{editorMode === 'guided' ? '确认三个业务选择,底层 Pipeline 自动生成' : '直接编辑有序 DAG 与节点引用'}</span></div><Switch checked={direction.enabled} onCheckedChange={(enabled) => patchDirection((current) => ({ ...current, enabled }))} /></div>
|
||||
<div className="transform-editor-mode" role="tablist" aria-label="Pipeline 编辑方式">
|
||||
<button type="button" className={editorMode === 'guided' ? 'is-selected' : ''} onClick={() => setEditorMode('guided')}><Sparkles size={13} />引导配置</button>
|
||||
<button type="button" className={editorMode === 'advanced' ? 'is-selected' : ''} onClick={() => setEditorMode('advanced')}><Code2 size={13} />高级 Pipeline</button>
|
||||
</div>
|
||||
|
||||
{editorMode === 'guided' && (!guide ? <div className="transform-guide-empty">
|
||||
<Sparkles size={20} />
|
||||
<div><strong>{direction.nodes.length ? '这条 Pipeline 包含高级结构' : '选择页面函数后自动生成'}</strong><span>{direction.nodes.length ? '高级结构不会被静默改写;可继续使用高级编辑,或明确替换成三步引导流程。' : '无需添加节点、引用或内置转换。'}</span></div>
|
||||
<Button size="sm" variant="primary" disabled={!callables.length} onClick={() => patchGuide(defaultGuidedTransform(callables[0]))}>{direction.nodes.length ? '替换为引导流程' : '开始配置'}</Button>
|
||||
</div> : guide && <div className="transform-guide">
|
||||
<div className="transform-guide-flow">
|
||||
<span>逻辑明文</span><ArrowRight size={13} /><strong>{guidedCallable?.name || '选择页面函数'}</strong><ArrowRight size={13} /><span>{guidedOutputDescription(guide)}</span>
|
||||
</div>
|
||||
|
||||
<section className="transform-guide-step">
|
||||
<span className="transform-guide-step__index">1</span>
|
||||
<div className="transform-guide-step__body">
|
||||
<header><div><strong>明文从哪里来</strong><span>通常选择整个逻辑 Body;多参数函数会逐项显示。</span></div><FileInput size={15} /></header>
|
||||
<div className="transform-guide-inputs">
|
||||
{guide.inputPaths.map((path, index) => {
|
||||
const source = splitInputPath(path);
|
||||
const slot = guidedCallable?.inputSlots.filter((item) => !item.retained)[index];
|
||||
const needsField = !['body', 'text'].includes(source.kind);
|
||||
return <div key={`${guide.callableId}:${index}`}>
|
||||
<label><span>{slot ? `${slot.name} · ${INPUT_ROLE_LABELS[slot.role]}` : `参数 ${index + 1}`}</span><select value={source.kind} onChange={(event) => {
|
||||
const kind = event.target.value as GuidedInputKind;
|
||||
const defaultField = kind === 'body-field' ? 'value' : kind === 'header-field' ? 'authorization' : kind === 'query-field' ? 'value' : kind === 'custom' ? 'body' : '';
|
||||
patchGuideInput(index, joinInputPath(kind, defaultField));
|
||||
}}><option value="body">整个逻辑 Body</option><option value="body-field">Body 中的字段</option><option value="text">原始 Body 文本</option><option value="header-field">Header 字段</option><option value="query-field">Query 参数</option><option value="custom">高级上下文路径</option></select></label>
|
||||
{needsField && <label><span>{source.kind === 'custom' ? '上下文路径' : '字段名'}</span><input value={source.field} onChange={(event) => patchGuideInput(index, joinInputPath(source.kind, event.target.value))} placeholder={source.kind === 'custom' ? 'body.account.id' : 'password'} /></label>}
|
||||
</div>;
|
||||
})}
|
||||
{!guide.inputPaths.length && <div className="transform-guide-note">这个页面函数不需要外部输入,将直接使用页面内保留的环境。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="transform-guide-step">
|
||||
<span className="transform-guide-step__index">2</span>
|
||||
<div className="transform-guide-step__body">
|
||||
<header><div><strong>交给哪个页面函数</strong><span>函数在当前页面文档中执行,Key、IV 与闭包值不会离开页面。</span></div><Code2 size={15} /></header>
|
||||
<label className="transform-guide-callable"><span>页面能力</span><select value={guide.callableId} onChange={(event) => selectGuidedCallable(event.target.value)}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>
|
||||
{guidedCallable && <div className="transform-guide-callable-meta"><span>{callableKindLabel(guidedCallable)}</span><strong>{guidedCallable.algorithm || guidedCallable.operation}</strong><em>{guidedCallable.inputSlots.filter((slot) => !slot.retained).length} 个明文参数</em></div>}
|
||||
<details className="transform-guide-result"><summary>函数返回的是对象,需要取其中一个字段</summary><label><span>返回字段路径</span><input value={guide.resultPath || ''} onChange={(event) => patchGuide({ ...guide, resultPath: event.target.value || undefined })} placeholder="例如 encryptedData;留空使用完整返回值" /></label></details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="transform-guide-step">
|
||||
<span className="transform-guide-step__index">3</span>
|
||||
<div className="transform-guide-step__body">
|
||||
<header><div><strong>线上请求写到哪里</strong><span>选择报文形态即可,字段组合与节点引用由插件生成。</span></div><ArrowDown size={15} /></header>
|
||||
<div className="transform-guide-output">
|
||||
<label><span>输出形态</span><select value={guide.outputKind} onChange={(event) => {
|
||||
const outputKind = event.target.value as GuidedTransformOutputKind;
|
||||
const outputField = outputKind === 'body' ? '' : guide.outputField || (outputKind === 'header' ? 'X-Sign' : outputKind === 'query' ? 'signature' : 'encryptedData');
|
||||
patchGuide({ ...guide, outputKind, outputField, setFormContentType: outputKind === 'form-field' });
|
||||
}}><option value="body">替换整个 Body</option><option value="json-field">写入 JSON 字段</option><option value="form-field">写入表单字段</option><option value="header">写入 Header</option><option value="query">写入 Query 参数</option></select></label>
|
||||
{guide.outputKind !== 'body' && <label><span>{outputFieldLabel(guide.outputKind)}</span><input value={guide.outputField} onChange={(event) => patchGuide({ ...guide, outputField: event.target.value })} placeholder={guide.outputKind === 'form-field' ? 'encryptedData' : guide.outputKind === 'header' ? 'X-Sign' : 'signature'} /></label>}
|
||||
</div>
|
||||
{guide.outputKind === 'form-field' && <label className="transform-guide-content-type"><Switch checked={guide.setFormContentType} onCheckedChange={(setFormContentType) => patchGuide({ ...guide, setFormContentType })} /><span><strong>自动设置表单 Content-Type</strong><small>生成 application/x-www-form-urlencoded,无需再添加固定值和 Header 节点。</small></span></label>}
|
||||
<div className={`transform-guide-ready ${guidedValid ? 'is-ready' : ''}`}><CheckCircle2 size={14} /><span>{guidedValid ? `将自动生成 ${direction.nodes.length} 个底层节点` : '补全页面函数、输入来源和输出字段后即可保存'}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>)}
|
||||
|
||||
{editorMode === 'advanced' && <>
|
||||
<div className="transform-advanced-notice"><Code2 size={14} /><span><strong>高级 Pipeline</strong>节点、引用和白名单操作会直接影响线上报文;常规加解密场景建议使用引导配置。</span></div>
|
||||
<div className="transform-node-list">
|
||||
{direction.nodes.map((node, index) => {
|
||||
const available = direction.nodes.slice(0, index);
|
||||
const references = referencesOf(node);
|
||||
return <section className="transform-node" key={node.id}>
|
||||
<div className="transform-node-index"><span>{index + 1}</span>{index < direction.nodes.length - 1 && <i />}</div>
|
||||
<div className="transform-node-fields">
|
||||
<header><em>{nodeLabel(node.kind)}</em><input value={node.name} onChange={(event) => patchNode(node.id, { name: event.target.value })} /><Button size="icon" variant="ghost" aria-label="删除节点" title="删除节点" onClick={() => patchDirection((current) => ({ ...current, nodes: current.nodes.filter((item) => item.id !== node.id) }))}><Trash2 size={13} /></Button></header>
|
||||
{node.kind === 'context.read' && <label><span>上下文路径</span><input value={node.path} onChange={(event) => patchNode(node.id, { path: event.target.value })} placeholder="body.password" /></label>}
|
||||
{node.kind === 'builtin' && <><label><span>白名单操作</span><select value={node.operation} onChange={(event) => patchNode(node.id, { operation: event.target.value as BrowserTransformBuiltinOperation })}>{BUILTINS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>{node.operation === 'value.literal' && <label><span>固定值</span><input value={typeof node.options?.value === 'string' ? node.options.value : ''} onChange={(event) => patchNode(node.id, { options: { value: event.target.value } })} /></label>}{['form.compose', 'object.compose'].includes(node.operation) && <label><span>字段名 · 按输入顺序</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="encryptedData, signature" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label>}{node.operation === 'object.pick' && <><label><span>读取路径</span><input value={Array.isArray(node.options?.paths) ? node.options.paths.join(', ') : ''} placeholder="account.id, profile.name" onChange={(event) => patchNode(node.id, { options: { ...node.options, paths: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label><label><span>输出字段名</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="accountId, name" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label></>}</>}
|
||||
{node.kind === 'page.call' && <label><span>页面函数</span><select value={node.callableId} onChange={(event) => patchNode(node.id, { callableId: event.target.value })}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>}
|
||||
{(node.kind === 'page.call' || (node.kind === 'builtin' && node.operation !== 'value.literal')) && <div className="transform-node-references"><span>输入引用</span>{references.map((reference, referenceIndex) => <div key={`${node.id}:${referenceIndex}`}><select value={reference.nodeId} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, nodeId: event.target.value } : item))}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select><input value={reference.path || ''} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, path: event.target.value || undefined } : item))} placeholder="可选子路径" /><Button size="icon" variant="ghost" aria-label="删除输入引用" onClick={() => patchReferences(node, references.filter((_, itemIndex) => itemIndex !== referenceIndex))}><Trash2 size={12} /></Button></div>)}<Button size="sm" variant="ghost" onClick={() => patchReferences(node, [...references, { nodeId: available.at(-1)?.id || '' }])}><Plus size={12} />输入</Button></div>}
|
||||
{node.kind === 'output.write' && <div className="transform-output-fields"><label><span>来源节点</span><select value={node.source.nodeId} onChange={(event) => patchNode(node.id, { source: { ...node.source, nodeId: event.target.value } })}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label><label><span>子路径</span><input value={node.source.path || ''} onChange={(event) => patchNode(node.id, { source: { ...node.source, path: event.target.value || undefined } })} placeholder="可选" /></label><label><span>写入目标</span><input value={node.destination} onChange={(event) => patchNode(node.id, { destination: event.target.value })} placeholder="body.encryptedData" /></label><label><span>编码</span><select value={node.encoding} onChange={(event) => patchNode(node.id, { encoding: event.target.value as 'auto' })}><option value="auto">自动</option><option value="text">文本</option><option value="json">JSON</option><option value="base64">Base64</option></select></label></div>}
|
||||
</div>
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
<div className="transform-node-add"><span>添加节点</span><Button size="sm" variant="ghost" onClick={() => addNode('context.read')}><FileInput size={13} />上下文</Button><Button size="sm" variant="ghost" onClick={() => addNode('builtin')}><Braces size={13} />内置转换</Button><Button size="sm" variant="ghost" onClick={() => addNode('page.call')}><Code2 size={13} />页面函数</Button><Button size="sm" variant="ghost" onClick={() => addNode('output.write')}><ArrowDown size={13} />输出</Button></div>
|
||||
</>}
|
||||
</div>}
|
||||
<footer className="transform-editor-actions"><span className={bindingReady ? 'is-ready' : 'is-stale'}><i />{bindingReady ? '当前页面函数可用' : '页面函数缺失或文档已变化'}</span><Button size="icon" variant="ghost" aria-label="删除配置" title="删除配置" onClick={() => void remove()}><Trash2 size={14} /></Button><Button variant="primary" disabled={busy || !dirty || (editorMode === 'guided' && Boolean(direction?.enabled) && (!guide || !guidedValid))} onClick={() => void save()}><Save size={14} />保存</Button></footer>
|
||||
</>}
|
||||
</main>
|
||||
|
||||
<aside className="transform-test">
|
||||
<header>
|
||||
<div><FlaskConical size={15} /><span><strong>本地回放</strong><small>不发送网络请求</small></span></div>
|
||||
<div className="transform-test-header-actions">
|
||||
{testResult && <i className="transform-test-duration">{testResult.durationMs.toFixed(1)} ms</i>}
|
||||
<span className={`transform-replay-persistence is-${replayPersistence}`} title={replayPersistenceTitle} aria-live="polite"><i />{replayPersistenceLabel(replayPersistence)}</span>
|
||||
<Button size="icon" variant="ghost" disabled={!draft?.id || busy || replayLoading} aria-label="清空本机回放草稿" title="清空当前方向的本机回放草稿" onClick={() => void clearReplay()}><Trash2 size={13} /></Button>
|
||||
</div>
|
||||
</header>
|
||||
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={testMethod} onChange={(event) => { setTestMethod(event.target.value); setTestResult(undefined); }} /><input disabled={replayLoading} aria-label="回放请求 URL" value={testUrl} onChange={(event) => { setTestUrl(event.target.value); setTestResult(undefined); }} placeholder="https://example.test/api" /></div></label>
|
||||
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={testHeaders} onChange={(event) => { setTestHeaders(event.target.value); setTestResult(undefined); }} /></label>
|
||||
<div className="transform-test-body"><div className="transform-test-field-label"><span>Body</span>{testSample && (testBody === testSample.body ? <em title={testSample.label}>短时样本</em> : <button type="button" disabled={replayLoading} onClick={() => { setTestBody(testSample.body); setTestResult(undefined); }}>恢复短时样本</button>)}</div><textarea disabled={replayLoading} aria-label="回放 Body" rows={8} value={testBody} onChange={(event) => { setTestBody(event.target.value); setTestResult(undefined); }} /></div>
|
||||
<Button variant="primary" disabled={!draft?.id || dirty || busy || replayLoading || !bindingReady} onClick={() => void execute()}><Play size={14} />执行 Pipeline</Button>
|
||||
{(loadError || replayStorageError || testError) && <div className="transform-test-error"><AlertTriangle size={14} />{loadError || replayStorageError || testError}</div>}
|
||||
{testResult && <section className="transform-test-result"><header><div><CheckCircle2 size={14} /><strong>转换完成</strong></div><span>{testResult.nodeDurations.length} 节点</span></header><dl><div><dt>输出 URL</dt><dd>{testResult.url}</dd></div><div><dt>Body Base64</dt><dd>{testResult.bodyBase64.slice(0, 64)}{testResult.bodyBase64.length > 64 ? '…' : ''}</dd></div><div><dt>Headers</dt><dd>{testResult.setHeaders.length} 设置 · {testResult.removeHeaders.length} 删除</dd></div></dl><pre>{JSON.stringify(testResult.logicalOutput, null, 2)}</pre><footer>{testResult.nodeDurations.map((node) => <span key={node.nodeId}>{node.nodeId.split('-')[0]} · {node.durationMs.toFixed(1)} ms</span>)}</footer></section>}
|
||||
</aside>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
.transform-workbench {
|
||||
min-height: 650px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 236px) minmax(430px, 1fr) minmax(300px, 350px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.transform-profiles,
|
||||
.transform-editor,
|
||||
.transform-test { min-width: 0; min-height: 0; }
|
||||
|
||||
.transform-profiles { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-right: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.transform-profiles > header { min-height: 46px; padding: 0 8px 0 13px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); }
|
||||
.transform-profiles > header > div { display: flex; align-items: center; gap: 7px; }
|
||||
.transform-profiles > header strong { font-size: var(--text-sm); }
|
||||
.transform-profiles > header span { min-width: 21px; height: 19px; padding: 0 5px; display: grid; place-items: center; border-radius: 999px; background: var(--surface); color: var(--muted); font-size: 10px; }
|
||||
.transform-profile-list { max-height: 760px; overflow: auto; }
|
||||
.transform-profile-list > button { width: 100%; min-height: 66px; padding: 10px 9px 10px 11px; display: grid; grid-template-columns: 28px minmax(0, 1fr) 16px; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
||||
.transform-profile-list > button:hover { background: var(--surface); }
|
||||
.transform-profile-list > button.is-selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.transform-profile-mark { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--muted); }
|
||||
.transform-profile-mark.is-ready { color: var(--success); }
|
||||
.transform-profile-list button > span:nth-child(2) { min-width: 0; }
|
||||
.transform-profile-list strong,
|
||||
.transform-profile-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-profile-list strong { font-size: var(--text-sm); font-weight: 650; }
|
||||
.transform-profile-list small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-profile-list button > i { color: var(--muted); font-style: normal; }
|
||||
.transform-profile-list button > i:has(.lucide-circle-check-big) { color: var(--success); }
|
||||
.transform-profile-empty { min-height: 220px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center; }
|
||||
.transform-profile-empty strong { color: var(--muted-strong); font-size: var(--text-sm); }
|
||||
.transform-profiles > footer { position: relative; z-index: 5; min-height: 44px; padding: 0 7px 0 12px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--border); }
|
||||
.transform-callable-menu { position: relative; min-width: 0; }
|
||||
.transform-callable-menu > summary { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--text-xs); cursor: pointer; list-style: none; }
|
||||
.transform-callable-menu > summary::-webkit-details-marker { display: none; }
|
||||
.transform-callable-menu > summary > svg { transition: transform 140ms ease; }
|
||||
.transform-callable-menu[open] > summary > svg { transform: rotate(180deg); }
|
||||
.transform-callable-menu > summary i,
|
||||
.transform-editor-actions > span i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||
.transform-callable-menu > summary.is-ready i,
|
||||
.transform-editor-actions > span.is-ready i { background: var(--success); }
|
||||
.transform-callable-popover { position: absolute; left: -5px; bottom: calc(100% + 11px); width: min(330px, calc(100vw - 40px)); max-height: 390px; overflow: auto; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); box-shadow: 0 16px 42px rgb(15 23 42 / 18%); }
|
||||
.transform-callable-popover::after { content: ''; position: absolute; left: 18px; bottom: -5px; width: 8px; height: 8px; border-right: 1px solid var(--border-strong); border-bottom: 1px solid var(--border-strong); background: var(--surface); transform: rotate(45deg); }
|
||||
.transform-callable-popover > header { min-height: 52px; padding: 9px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); }
|
||||
.transform-callable-popover > header strong,
|
||||
.transform-callable-popover > header span { display: block; }
|
||||
.transform-callable-popover > header strong { font-size: var(--text-sm); }
|
||||
.transform-callable-popover > header span { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.transform-callable-popover > header em { min-width: 21px; height: 20px; display: grid; place-items: center; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: 10px; font-style: normal; }
|
||||
.transform-callable-empty { min-height: 92px; padding: 16px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-callable-list > section + section { border-top: 1px solid var(--border); }
|
||||
.transform-callable-row { min-height: 58px; padding: 8px 7px 8px 11px; display: grid; grid-template-columns: minmax(0, 1fr) 32px; align-items: center; gap: 8px; }
|
||||
.transform-callable-row > span { min-width: 0; }
|
||||
.transform-callable-row strong,
|
||||
.transform-callable-row small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-callable-row strong { font-size: var(--text-xs); }
|
||||
.transform-callable-row small { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.transform-callable-confirm { padding: 8px 10px 10px; display: grid; gap: 8px; border-top: 1px solid color-mix(in srgb, var(--danger) 22%, var(--border)); background: var(--danger-soft); }
|
||||
.transform-callable-confirm > span { color: var(--danger); font-size: 10px; line-height: 1.45; }
|
||||
.transform-callable-confirm > div { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
|
||||
.transform-editor { max-height: 820px; overflow: auto; display: grid; align-content: start; border-right: 1px solid var(--border); }
|
||||
.transform-editor-empty { min-height: 520px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: var(--muted); text-align: center; }
|
||||
.transform-editor-empty strong { color: var(--foreground); font-size: var(--text-md); }
|
||||
.transform-editor-head { min-height: 64px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); }
|
||||
.transform-editor-head > div { min-width: 0; flex: 1; }
|
||||
.transform-editor-head input { width: 100%; height: 27px; padding: 0; border: 0; background: transparent; font-size: var(--text-lg); font-weight: 680; }
|
||||
.transform-editor-head input:focus-visible { box-shadow: none; }
|
||||
.transform-editor-head > div > span { display: block; margin-top: 2px; overflow: hidden; color: var(--muted); font-size: var(--text-xs); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-editor-head > label { display: flex; align-items: center; gap: 7px; color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.transform-route { padding: 10px 14px; display: grid; grid-template-columns: minmax(92px, .35fr) minmax(170px, 1fr) 70px; gap: 8px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.transform-route label,
|
||||
.transform-step-fields label,
|
||||
.transform-output-list label,
|
||||
.transform-test > label { min-width: 0; display: grid; gap: 4px; }
|
||||
.transform-route label > span,
|
||||
.transform-step-fields label > span,
|
||||
.transform-output-list label > span,
|
||||
.transform-test > label > span { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.transform-route input,
|
||||
.transform-route select,
|
||||
.transform-step-fields input,
|
||||
.transform-step-fields select,
|
||||
.transform-output-list input,
|
||||
.transform-output-list select { height: 32px; font-size: var(--text-xs); }
|
||||
.transform-direction-tabs { height: 48px; padding: 0 14px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--border); }
|
||||
.transform-direction-tabs button { min-width: 0; padding: 0 8px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted-strong); font: inherit; font-size: var(--text-sm); cursor: pointer; }
|
||||
.transform-direction-tabs button.is-selected { border-bottom-color: var(--primary); color: var(--foreground); font-weight: 650; }
|
||||
.transform-direction-tabs i { padding: 2px 5px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted); font-size: 10px; font-style: normal; }
|
||||
.transform-direction-tabs i.is-enabled { background: var(--success-soft); color: var(--success); }
|
||||
.transform-pipeline-editor { padding: 14px; display: grid; gap: 12px; }
|
||||
.transform-direction-state { min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.transform-direction-state strong,
|
||||
.transform-direction-state span { display: block; }
|
||||
.transform-direction-state strong { font-size: var(--text-sm); }
|
||||
.transform-direction-state span { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-editor-mode { width: fit-content; padding: 2px; display: flex; gap: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-subtle); }
|
||||
.transform-editor-mode button { height: 28px; padding: 0 9px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: calc(var(--radius-sm) - 2px); background: transparent; color: var(--muted); font: inherit; font-size: var(--text-xs); cursor: pointer; transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease; }
|
||||
.transform-editor-mode button:hover { color: var(--foreground); }
|
||||
.transform-editor-mode button.is-selected { background: var(--surface); color: var(--foreground); box-shadow: 0 1px 2px rgb(15 23 42 / 8%); font-weight: 650; }
|
||||
.transform-guide { display: grid; gap: 0; animation: transform-guide-in 160ms ease-out both; }
|
||||
.transform-guide-flow { min-height: 38px; padding: 0 10px; display: flex; align-items: center; gap: 7px; overflow: hidden; border-left: 3px solid var(--primary); background: var(--primary-soft); color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
.transform-guide-flow > span,
|
||||
.transform-guide-flow > strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-guide-flow > strong { color: var(--primary-text); }
|
||||
.transform-guide-flow svg { flex: 0 0 auto; color: var(--primary); }
|
||||
.transform-guide-step { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 27px minmax(0, 1fr); gap: 9px; border-bottom: 1px solid var(--border); }
|
||||
.transform-guide-step:last-child { border-bottom: 0; }
|
||||
.transform-guide-step__index { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; background: var(--foreground); color: var(--background); font-size: 10px; font-weight: 750; }
|
||||
.transform-guide-step__body { min-width: 0; display: grid; gap: 10px; }
|
||||
.transform-guide-step__body > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.transform-guide-step__body > header strong,
|
||||
.transform-guide-step__body > header span { display: block; }
|
||||
.transform-guide-step__body > header strong { font-size: var(--text-sm); }
|
||||
.transform-guide-step__body > header span { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); line-height: 1.45; }
|
||||
.transform-guide-step__body > header svg { flex: 0 0 auto; color: var(--muted); }
|
||||
.transform-guide-inputs { display: grid; gap: 7px; }
|
||||
.transform-guide-inputs > div { min-width: 0; display: grid; grid-template-columns: minmax(145px, .9fr) minmax(120px, 1.1fr); gap: 7px; }
|
||||
.transform-guide-inputs label,
|
||||
.transform-guide-callable,
|
||||
.transform-guide-output label,
|
||||
.transform-guide-result label { min-width: 0; display: grid; gap: 4px; }
|
||||
.transform-guide-inputs label > span,
|
||||
.transform-guide-callable > span,
|
||||
.transform-guide-output label > span,
|
||||
.transform-guide-result label > span { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.transform-guide-inputs input,
|
||||
.transform-guide-inputs select,
|
||||
.transform-guide-callable select,
|
||||
.transform-guide-output input,
|
||||
.transform-guide-output select,
|
||||
.transform-guide-result input { min-width: 0; height: 34px; font-size: var(--text-xs); }
|
||||
.transform-guide-callable-meta { min-height: 34px; padding: 7px 9px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; background: var(--surface-subtle); font-size: var(--text-xs); }
|
||||
.transform-guide-callable-meta span { color: var(--primary-text); font-weight: 650; }
|
||||
.transform-guide-callable-meta strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-guide-callable-meta em { color: var(--muted); font-style: normal; }
|
||||
.transform-guide-result { color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-guide-result summary { width: fit-content; cursor: pointer; }
|
||||
.transform-guide-result[open] summary { margin-bottom: 7px; color: var(--foreground); }
|
||||
.transform-guide-output { display: grid; grid-template-columns: minmax(160px, .9fr) minmax(130px, 1.1fr); gap: 7px; }
|
||||
.transform-guide-content-type { min-height: 44px; padding: 7px 9px; display: flex; align-items: center; gap: 9px; background: var(--surface-subtle); }
|
||||
.transform-guide-content-type strong,
|
||||
.transform-guide-content-type small { display: block; }
|
||||
.transform-guide-content-type strong { font-size: var(--text-xs); }
|
||||
.transform-guide-content-type small { margin-top: 2px; color: var(--muted); font-size: 10px; line-height: 1.4; }
|
||||
.transform-guide-note { padding: 8px 9px; background: var(--surface-subtle); color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-guide-ready { min-height: 32px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-guide-ready.is-ready { color: var(--success); }
|
||||
.transform-guide-empty { min-height: 132px; padding: 20px 12px; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 10px; border: 1px dashed var(--border-strong); color: var(--muted); }
|
||||
.transform-guide-empty > svg { color: var(--primary); }
|
||||
.transform-guide-empty strong,
|
||||
.transform-guide-empty span { display: block; }
|
||||
.transform-guide-empty strong { color: var(--foreground); font-size: var(--text-sm); }
|
||||
.transform-guide-empty span { margin-top: 3px; font-size: var(--text-xs); line-height: 1.5; }
|
||||
.transform-advanced-notice { min-height: 38px; padding: 7px 9px; display: flex; align-items: flex-start; gap: 7px; border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; animation: transform-guide-in 140ms ease-out both; }
|
||||
.transform-advanced-notice svg { flex: 0 0 auto; margin-top: 1px; color: var(--warning); }
|
||||
@keyframes transform-guide-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.transform-node-list { display: grid; gap: 0; }
|
||||
.transform-node { min-width: 0; display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: stretch; }
|
||||
.transform-node-index { display: grid; grid-template-rows: 24px minmax(0, 1fr); justify-items: center; }
|
||||
.transform-node-index span { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; background: var(--foreground); color: var(--background); font-size: 10px; font-weight: 700; }
|
||||
.transform-node-index i { width: 1px; min-height: 22px; background: var(--border-strong); }
|
||||
.transform-node-fields { min-width: 0; margin-bottom: 9px; padding: 9px; display: grid; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.transform-node-fields > header { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) 30px; align-items: center; gap: 8px; }
|
||||
.transform-node-fields > header em { padding: 2px 5px; border-radius: var(--radius-sm); background: var(--primary-soft); color: var(--primary-text); font-size: 10px; font-style: normal; font-weight: 650; white-space: nowrap; }
|
||||
.transform-node-fields > header input { height: 28px; padding-inline: 5px; border-color: transparent; background: transparent; font-weight: 650; }
|
||||
.transform-node-fields > header input:focus { border-color: var(--border-strong); background: var(--surface-subtle); }
|
||||
.transform-node-fields > label,
|
||||
.transform-output-fields label { min-width: 0; display: grid; gap: 4px; }
|
||||
.transform-node-fields label > span,
|
||||
.transform-node-references > span,
|
||||
.transform-output-fields label > span { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.transform-node-fields input,
|
||||
.transform-node-fields select { min-width: 0; height: 32px; font-size: var(--text-xs); }
|
||||
.transform-node-references { display: grid; gap: 6px; }
|
||||
.transform-node-references > div { min-width: 0; display: grid; grid-template-columns: minmax(110px, .8fr) minmax(110px, 1fr) 30px; align-items: center; gap: 6px; }
|
||||
.transform-node-references > .ui-button { justify-self: start; }
|
||||
.transform-output-fields { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(90px, .8fr); gap: 7px; }
|
||||
.transform-node-add { min-height: 42px; padding-top: 8px; display: flex; align-items: center; flex-wrap: wrap; gap: 5px; border-top: 1px solid var(--border); }
|
||||
.transform-node-add > span { margin-right: 3px; color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.transform-flow-label { min-height: 34px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-left: 3px solid var(--primary); background: var(--primary-soft); }
|
||||
.transform-flow-label span { font-size: var(--text-xs); font-weight: 650; }
|
||||
.transform-flow-label code { overflow: hidden; color: var(--primary-text); font-size: 10px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-step-list { display: grid; gap: 0; }
|
||||
.transform-step { min-width: 0; display: grid; grid-template-columns: 26px minmax(0, 1fr) 32px; gap: 8px; align-items: start; }
|
||||
.transform-step-index { height: 100%; display: grid; grid-template-rows: 24px minmax(0, 1fr); justify-items: center; }
|
||||
.transform-step-index span { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: var(--foreground); color: var(--background); font-size: 10px; font-weight: 700; }
|
||||
.transform-step-index i { width: 1px; min-height: 62px; background: var(--border-strong); }
|
||||
.transform-step-fields { padding: 9px; margin-bottom: 9px; display: grid; grid-template-columns: minmax(120px, .9fr) minmax(150px, 1.1fr); gap: 7px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
.transform-step-fields .transform-step-name { grid-column: 1; }
|
||||
.transform-step > .ui-button { margin-top: 6px; color: var(--muted); }
|
||||
.transform-add { justify-self: start; }
|
||||
.transform-output-heading { min-height: 42px; padding-top: 8px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--border); }
|
||||
.transform-output-heading strong,
|
||||
.transform-output-heading span { display: block; }
|
||||
.transform-output-heading strong { font-size: var(--text-sm); }
|
||||
.transform-output-heading span { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-output-heading svg { color: var(--muted); }
|
||||
.transform-output-list { display: grid; gap: 7px; }
|
||||
.transform-output-list > div { min-width: 0; display: grid; grid-template-columns: minmax(90px, 1fr) 14px minmax(100px, 1fr) 78px 32px; align-items: end; gap: 6px; }
|
||||
.transform-output-list > div > svg { margin-bottom: 9px; color: var(--muted); }
|
||||
.transform-editor-actions { min-height: 58px; padding: 9px 14px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); background: var(--surface); position: sticky; bottom: 0; z-index: 2; }
|
||||
.transform-editor-actions > span { margin-right: auto; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.transform-editor-actions > span.is-stale { color: var(--danger); }
|
||||
.transform-editor-actions > span.is-stale i { background: var(--danger); }
|
||||
|
||||
.transform-test { max-height: 820px; padding: 13px; overflow: auto; display: grid; gap: 11px; align-content: start; background: var(--surface-subtle); }
|
||||
.transform-test > header { min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.transform-test > header > div { display: flex; align-items: center; gap: 8px; }
|
||||
.transform-test > header svg { color: var(--primary); }
|
||||
.transform-test > header strong,
|
||||
.transform-test > header small { display: block; }
|
||||
.transform-test > header strong { font-size: var(--text-sm); }
|
||||
.transform-test > header small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||
.transform-test > header > i { color: var(--success); font-size: var(--text-xs); font-style: normal; }
|
||||
.transform-test-header-actions { min-width: 0; justify-content: flex-end; gap: 6px !important; }
|
||||
.transform-test-header-actions > .ui-button { width: 26px; height: 26px; color: var(--muted); }
|
||||
.transform-test-header-actions > .ui-button svg { color: currentColor; }
|
||||
.transform-test-duration { color: var(--success); font-size: 10px; font-style: normal; white-space: nowrap; }
|
||||
.transform-replay-persistence { min-width: 0; display: inline-flex; align-items: center; gap: 5px; overflow: hidden; color: var(--muted); font-size: 9px; font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-replay-persistence > i { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--muted); }
|
||||
.transform-replay-persistence.is-ready > i,
|
||||
.transform-replay-persistence.is-saved > i { background: var(--success); }
|
||||
.transform-replay-persistence.is-loading > i,
|
||||
.transform-replay-persistence.is-saving > i { background: var(--primary); }
|
||||
.transform-replay-persistence.is-too-large,
|
||||
.transform-replay-persistence.is-error { color: var(--danger); }
|
||||
.transform-replay-persistence.is-too-large > i,
|
||||
.transform-replay-persistence.is-error > i { background: var(--danger); }
|
||||
.transform-test > label > div { min-width: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 6px; }
|
||||
.transform-test input { height: 32px; font-size: var(--text-xs); }
|
||||
.transform-test textarea { min-width: 0; resize: vertical; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.transform-test-body { min-width: 0; display: grid; gap: 4px; }
|
||||
.transform-test-field-label { min-height: 20px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.transform-test-field-label > span { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.transform-test-field-label > em { padding: 2px 6px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 9px; font-style: normal; font-weight: 650; }
|
||||
.transform-test-field-label > button { padding: 0; border: 0; background: transparent; color: var(--primary-text); font: inherit; font-size: 10px; font-weight: 650; cursor: pointer; }
|
||||
.transform-test-field-label > button:hover { text-decoration: underline; }
|
||||
.transform-test-field-label > button:disabled { color: var(--muted); cursor: default; text-decoration: none; }
|
||||
.transform-test-body textarea { min-height: 150px; }
|
||||
.transform-test-error { padding: 9px; display: flex; align-items: flex-start; gap: 7px; border-left: 3px solid var(--danger); background: var(--danger-soft); color: var(--danger); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.transform-test-error svg { flex: 0 0 auto; }
|
||||
.transform-test-result { display: grid; gap: 8px; border-top: 1px solid var(--border); padding-top: 10px; }
|
||||
.transform-test-result > header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.transform-test-result > header > div { display: flex; align-items: center; gap: 6px; }
|
||||
.transform-test-result > header strong { font-size: var(--text-sm); }
|
||||
.transform-test-result dl { margin: 0; display: grid; gap: 4px; }
|
||||
.transform-test-result dl > div { min-width: 0; display: grid; grid-template-columns: minmax(80px, .4fr) minmax(0, 1fr); gap: 7px; font-size: var(--text-xs); }
|
||||
.transform-test-result dt { color: var(--muted); }
|
||||
.transform-test-result dd { margin: 0; overflow: hidden; font-family: var(--font-mono); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.transform-test-result pre { max-height: 240px; padding: 9px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: var(--text-xs); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.transform-test-result > footer { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.transform-test-result > footer span { padding: 2px 5px; border-radius: var(--radius-sm); background: var(--surface); color: var(--muted); font-size: 10px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.transform-workbench { grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.transform-test { grid-column: 1 / -1; max-height: none; border-top: 1px solid var(--border); }
|
||||
.transform-editor { border-right: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.transform-workbench { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-profiles { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.transform-profile-list { max-height: 280px; }
|
||||
.transform-editor { max-height: none; border-bottom: 1px solid var(--border); }
|
||||
.transform-test { grid-column: auto; }
|
||||
.transform-route { grid-template-columns: minmax(90px, .35fr) minmax(0, 1fr); }
|
||||
.transform-route label:last-child { grid-column: 1 / -1; }
|
||||
.transform-step-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-step-fields .transform-step-name { grid-column: auto; }
|
||||
.transform-output-list > div { grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr) 32px; }
|
||||
.transform-output-list > div > select { grid-column: 1 / 4; }
|
||||
.transform-node-references > div,
|
||||
.transform-output-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-node-references > div > .ui-button { justify-self: end; }
|
||||
.transform-guide-inputs > div,
|
||||
.transform-guide-output { grid-template-columns: minmax(0, 1fr); }
|
||||
.transform-guide-empty { grid-template-columns: 28px minmax(0, 1fr); }
|
||||
.transform-guide-empty > .ui-button { grid-column: 1 / -1; justify-self: start; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.transform-guide,
|
||||
.transform-advanced-notice { animation: none; }
|
||||
.transform-editor-mode button { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { acquireTransformExecutionGate, createTransformExecutionGate } from './concurrency';
|
||||
|
||||
describe('browser transform concurrency gate', () => {
|
||||
it('transfers a released permit to the oldest waiter without overcommitting', async () => {
|
||||
const gate = createTransformExecutionGate();
|
||||
const releaseFirst = await acquireTransformExecutionGate(gate, 1, 2);
|
||||
const second = acquireTransformExecutionGate(gate, 1, 2);
|
||||
|
||||
expect(gate).toMatchObject({ active: 1, queued: 1 });
|
||||
releaseFirst();
|
||||
|
||||
// The woken waiter owns the permit before its promise continuation runs.
|
||||
const third = acquireTransformExecutionGate(gate, 1, 2);
|
||||
expect(gate).toMatchObject({ active: 1, queued: 2 });
|
||||
|
||||
const releaseSecond = await second;
|
||||
expect(gate).toMatchObject({ active: 1, queued: 1 });
|
||||
releaseSecond();
|
||||
|
||||
const releaseThird = await third;
|
||||
expect(gate).toMatchObject({ active: 1, queued: 0 });
|
||||
releaseThird();
|
||||
releaseThird();
|
||||
expect(gate).toMatchObject({ active: 0, queued: 0 });
|
||||
});
|
||||
|
||||
it('fails before adding work beyond the bounded queue', async () => {
|
||||
const gate = createTransformExecutionGate();
|
||||
const release = await acquireTransformExecutionGate(gate, 1, 1);
|
||||
const waiting = acquireTransformExecutionGate(gate, 1, 1);
|
||||
|
||||
await expect(acquireTransformExecutionGate(gate, 1, 1)).rejects.toThrow('队列已满');
|
||||
release();
|
||||
const releaseWaiting = await waiting;
|
||||
releaseWaiting();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export interface TransformExecutionGate {
|
||||
active: number;
|
||||
queued: number;
|
||||
waiters: Array<() => void>;
|
||||
}
|
||||
|
||||
export function createTransformExecutionGate(): TransformExecutionGate {
|
||||
return { active: 0, queued: 0, waiters: [] };
|
||||
}
|
||||
|
||||
export async function acquireTransformExecutionGate(
|
||||
gate: TransformExecutionGate,
|
||||
maxConcurrency: number,
|
||||
maxQueueDepth: number,
|
||||
): Promise<() => void> {
|
||||
if (gate.active < maxConcurrency) {
|
||||
gate.active += 1;
|
||||
} else {
|
||||
if (gate.queued >= maxQueueDepth) {
|
||||
throw new ExtensionError('transform_queue_full', '页面转换队列已满,请降低并发或增加配置并发数');
|
||||
}
|
||||
gate.queued += 1;
|
||||
await new Promise<void>((resolve) => gate.waiters.push(resolve));
|
||||
gate.queued -= 1;
|
||||
// The releasing operation transfers its active permit directly to this waiter.
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const next = gate.waiters.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
gate.active = Math.max(0, gate.active - 1);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserPageCallable, BrowserTransformPacket } from '@/types/models';
|
||||
import { executeTransformDirection } from './mapping';
|
||||
import { compileGuidedTransform, defaultGuidedTransform, parseGuidedTransform } from './guided';
|
||||
|
||||
const callable: BrowserPageCallable = {
|
||||
id: 'encrypt-aes',
|
||||
name: '页面 AES-CBC 加密',
|
||||
kind: 'recorded-call',
|
||||
operation: 'AES.encrypt',
|
||||
algorithm: 'AES.encrypt',
|
||||
origin: 'https://example.test',
|
||||
target: { tabId: 1, frameId: 0, documentId: 'document-1' },
|
||||
lifecycle: 'document',
|
||||
execution: { resultMode: 'sync', timeoutMs: 8_000 },
|
||||
inputSlots: [{ id: 'data', name: 'data', index: 0, role: 'data', dataType: 'string', required: true, retained: false }],
|
||||
output: { dataType: 'CipherParams', encoding: 'auto', shape: 'value', paths: [] },
|
||||
provenance: { eventId: 'crypto-1' },
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
function bodyBase64(value: unknown): string {
|
||||
const valueText = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const bytes = new TextEncoder().encode(valueText);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBody(value: string): string {
|
||||
const binary = atob(value);
|
||||
return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
|
||||
}
|
||||
|
||||
describe('guided browser transform compiler', () => {
|
||||
it('maps a captured business closure to parameter-level body fields', async () => {
|
||||
const businessCallable: BrowserPageCallable = {
|
||||
...callable,
|
||||
id: 'login-envelope',
|
||||
kind: 'business-closure',
|
||||
operation: 'buildLoginEnvelope',
|
||||
inputSlots: [
|
||||
{ id: 'arg-0', name: 'password', index: 0, role: 'unknown', dataType: 'unknown', required: true, retained: false },
|
||||
{ id: 'arg-1', name: 'account', index: 1, role: 'unknown', dataType: 'unknown', required: true, retained: false },
|
||||
],
|
||||
};
|
||||
const guide = defaultGuidedTransform(businessCallable);
|
||||
expect(guide.inputPaths).toEqual(['body.password', 'body.account']);
|
||||
const direction = compileGuidedTransform(guide, businessCallable);
|
||||
let receivedArgs: unknown[] = [];
|
||||
await executeTransformDirection('profile-business', 'request', direction, {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/login',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
bodyBase64: bodyBase64({ password: '123456', account: 'admin' }),
|
||||
}, async (callableId, args) => {
|
||||
receivedArgs = args;
|
||||
return { callableId, type: 'object', preview: 'Object', value: { ciphertext: 'value' }, durationMs: 1 };
|
||||
});
|
||||
expect(receivedArgs).toEqual(['123456', 'admin']);
|
||||
});
|
||||
|
||||
it('passes the whole body to a single business parameter or unnamed fallback', () => {
|
||||
expect(defaultGuidedTransform({
|
||||
...callable,
|
||||
kind: 'business-closure',
|
||||
inputSlots: [{ ...callable.inputSlots[0], name: 'payload' }],
|
||||
}).inputPaths).toEqual(['body']);
|
||||
expect(defaultGuidedTransform({
|
||||
...callable,
|
||||
kind: 'business-closure',
|
||||
inputSlots: [
|
||||
{ ...callable.inputSlots[0], name: 'arg0' },
|
||||
{ ...callable.inputSlots[0], id: 'arg-1', name: 'options', index: 1 },
|
||||
],
|
||||
}).inputPaths).toEqual(['body', 'body.options']);
|
||||
});
|
||||
|
||||
it('compiles a form field and its content type without exposing DAG details', async () => {
|
||||
const guide = {
|
||||
...defaultGuidedTransform(callable, { outputKind: 'form-field', outputField: 'encryptedData' }),
|
||||
setFormContentType: true,
|
||||
};
|
||||
const direction = compileGuidedTransform(guide, callable);
|
||||
const packet: BrowserTransformPacket = {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/encrypt/aes.php',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
bodyBase64: bodyBase64({ username: 'admin', password: '123456' }),
|
||||
};
|
||||
const result = await executeTransformDirection('profile-1', 'request', direction, packet, async (callableId, args) => ({
|
||||
callableId,
|
||||
type: 'string',
|
||||
preview: 'cipher/value+',
|
||||
value: `cipher:${JSON.stringify(args[0])}`,
|
||||
durationMs: 1,
|
||||
}));
|
||||
|
||||
expect(decodeBody(result.bodyBase64)).toBe(`encryptedData=${encodeURIComponent('cipher:{"username":"admin","password":"123456"}')}`);
|
||||
expect(result.setHeaders).toContainEqual({ name: 'Content-Type', value: 'application/x-www-form-urlencoded' });
|
||||
expect(parseGuidedTransform(direction, [callable])).toMatchObject({
|
||||
callableId: callable.id,
|
||||
inputPaths: ['body'],
|
||||
outputKind: 'form-field',
|
||||
outputField: 'encryptedData',
|
||||
setFormContentType: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['json-field', 'encryptedData', 'body.encryptedData'],
|
||||
['header', 'X-Sign', 'header.X-Sign'],
|
||||
['query', 'signature', 'query.signature'],
|
||||
] as const)('compiles %s intent to an explicit output destination', (outputKind, outputField, destination) => {
|
||||
const direction = compileGuidedTransform({
|
||||
...defaultGuidedTransform(callable), outputKind, outputField,
|
||||
}, callable);
|
||||
const output = direction.nodes.find((node) => node.kind === 'output.write');
|
||||
expect(output).toMatchObject({ destination });
|
||||
expect(parseGuidedTransform(direction, [callable])).toMatchObject({ outputKind, outputField });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
BrowserPageCallable,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformPipelineNode,
|
||||
} from '@/types/models';
|
||||
|
||||
export type GuidedTransformOutputKind = 'body' | 'json-field' | 'form-field' | 'header' | 'query';
|
||||
|
||||
export interface GuidedTransformDraft {
|
||||
callableId: string;
|
||||
inputPaths: string[];
|
||||
resultPath?: string;
|
||||
outputKind: GuidedTransformOutputKind;
|
||||
outputField: string;
|
||||
setFormContentType: boolean;
|
||||
}
|
||||
|
||||
export interface GuidedTransformSuggestion {
|
||||
outputKind?: GuidedTransformOutputKind;
|
||||
outputField?: string;
|
||||
}
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function activeInputCount(callable?: BrowserPageCallable): number {
|
||||
if (!callable) return 1;
|
||||
return Math.max(0, callable.inputSlots.filter((slot) => !slot.retained).length);
|
||||
}
|
||||
|
||||
function defaultInputPaths(callable?: BrowserPageCallable): string[] {
|
||||
if (!callable) return ['body'];
|
||||
const slots = callable.inputSlots.filter((slot) => !slot.retained);
|
||||
if (slots.length <= 1) return slots.map(() => 'body');
|
||||
return slots.map((slot) => (
|
||||
callable.kind === 'business-closure'
|
||||
&& /^[A-Za-z_$][\w$]*$/.test(slot.name)
|
||||
&& !/^arg\d+$/.test(slot.name)
|
||||
? `body.${slot.name}`
|
||||
: 'body'
|
||||
));
|
||||
}
|
||||
|
||||
export function defaultGuidedTransform(
|
||||
callable?: BrowserPageCallable,
|
||||
suggestion: GuidedTransformSuggestion = {},
|
||||
): GuidedTransformDraft {
|
||||
return {
|
||||
callableId: callable?.id || '',
|
||||
inputPaths: defaultInputPaths(callable),
|
||||
outputKind: suggestion.outputKind || 'body',
|
||||
outputField: suggestion.outputField || '',
|
||||
setFormContentType: suggestion.outputKind === 'form-field',
|
||||
};
|
||||
}
|
||||
|
||||
export function compileGuidedTransform(guide: GuidedTransformDraft, callable?: BrowserPageCallable): BrowserTransformDirection {
|
||||
const expectedInputs = activeInputCount(callable);
|
||||
const paths = guide.inputPaths.slice(0, expectedInputs);
|
||||
while (paths.length < expectedInputs) paths.push('body');
|
||||
|
||||
const inputNodes = paths.map((path, index): BrowserTransformPipelineNode => ({
|
||||
id: uid('input'),
|
||||
name: expectedInputs > 1 ? `读取参数 ${index + 1}` : '读取明文输入',
|
||||
kind: 'context.read',
|
||||
path: path.trim() || 'body',
|
||||
}));
|
||||
const callId = uid('call');
|
||||
const callNode: BrowserTransformPipelineNode = {
|
||||
id: callId,
|
||||
name: callable?.name || '调用页面函数',
|
||||
kind: 'page.call',
|
||||
callableId: guide.callableId,
|
||||
arguments: inputNodes.map((node) => ({ nodeId: node.id })),
|
||||
};
|
||||
const callReference = { nodeId: callId, path: guide.resultPath?.trim() || undefined };
|
||||
const nodes: BrowserTransformPipelineNode[] = [...inputNodes, callNode];
|
||||
|
||||
if (guide.outputKind === 'form-field') {
|
||||
const formId = uid('form');
|
||||
const field = guide.outputField.trim();
|
||||
nodes.push({
|
||||
id: formId,
|
||||
name: `组成表单字段 ${field || 'value'}`,
|
||||
kind: 'builtin',
|
||||
operation: 'form.compose',
|
||||
inputs: [callReference],
|
||||
options: { keys: [field] },
|
||||
});
|
||||
if (guide.setFormContentType) {
|
||||
const contentTypeId = uid('literal');
|
||||
nodes.push({
|
||||
id: contentTypeId,
|
||||
name: '表单 Content-Type',
|
||||
kind: 'builtin',
|
||||
operation: 'value.literal',
|
||||
inputs: [],
|
||||
options: { value: 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
nodes.push({
|
||||
id: uid('header'),
|
||||
name: '设置表单 Content-Type',
|
||||
kind: 'output.write',
|
||||
destination: 'header.Content-Type',
|
||||
source: { nodeId: contentTypeId },
|
||||
encoding: 'text',
|
||||
});
|
||||
}
|
||||
nodes.push({
|
||||
id: uid('output'),
|
||||
name: '写入线上表单',
|
||||
kind: 'output.write',
|
||||
destination: 'body',
|
||||
source: { nodeId: formId },
|
||||
encoding: 'text',
|
||||
});
|
||||
return { enabled: true, nodes };
|
||||
}
|
||||
|
||||
const field = guide.outputField.trim();
|
||||
const destination = guide.outputKind === 'body' ? 'body'
|
||||
: guide.outputKind === 'json-field' ? `body.${field}`
|
||||
: guide.outputKind === 'header' ? `header.${field}`
|
||||
: `query.${field}`;
|
||||
nodes.push({
|
||||
id: uid('output'),
|
||||
name: guide.outputKind === 'body' ? '替换线上 Body' : `写入 ${field || '输出字段'}`,
|
||||
kind: 'output.write',
|
||||
destination,
|
||||
source: callReference,
|
||||
encoding: guide.outputKind === 'body' ? 'auto' : 'text',
|
||||
});
|
||||
return { enabled: true, nodes };
|
||||
}
|
||||
|
||||
function referenceFromCall(
|
||||
nodeId: string,
|
||||
path: string | undefined,
|
||||
callId: string,
|
||||
): string | undefined {
|
||||
return nodeId === callId ? path : undefined;
|
||||
}
|
||||
|
||||
export function parseGuidedTransform(
|
||||
direction: BrowserTransformDirection,
|
||||
callables: BrowserPageCallable[],
|
||||
): GuidedTransformDraft | undefined {
|
||||
const calls = direction.nodes.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call');
|
||||
if (calls.length !== 1) return undefined;
|
||||
const call = calls[0];
|
||||
const callable = callables.find((item) => item.id === call.callableId);
|
||||
const byId = new Map(direction.nodes.map((node) => [node.id, node]));
|
||||
const inputPaths: string[] = [];
|
||||
for (const reference of call.arguments) {
|
||||
const source = byId.get(reference.nodeId);
|
||||
if (!source || source.kind !== 'context.read' || reference.path) return undefined;
|
||||
inputPaths.push(source.path);
|
||||
}
|
||||
|
||||
const outputs = direction.nodes.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'output.write' }> => node.kind === 'output.write');
|
||||
const form = direction.nodes.find((node): node is Extract<BrowserTransformPipelineNode, { kind: 'builtin' }> => (
|
||||
node.kind === 'builtin' && node.operation === 'form.compose'
|
||||
));
|
||||
if (form) {
|
||||
const bodyOutput = outputs.find((node) => node.destination === 'body' && node.source.nodeId === form.id);
|
||||
const keys = form.options?.keys;
|
||||
if (!bodyOutput || form.inputs.length !== 1 || form.inputs[0].nodeId !== call.id
|
||||
|| !Array.isArray(keys) || keys.length !== 1 || typeof keys[0] !== 'string') return undefined;
|
||||
const contentTypeOutput = outputs.find((node) => node.destination.toLowerCase() === 'header.content-type');
|
||||
if (outputs.some((node) => node !== bodyOutput && node !== contentTypeOutput)) return undefined;
|
||||
return {
|
||||
callableId: call.callableId,
|
||||
inputPaths,
|
||||
resultPath: form.inputs[0].path,
|
||||
outputKind: 'form-field',
|
||||
outputField: keys[0],
|
||||
setFormContentType: Boolean(contentTypeOutput),
|
||||
};
|
||||
}
|
||||
|
||||
if (outputs.length !== 1) return undefined;
|
||||
const output = outputs[0];
|
||||
const resultPath = referenceFromCall(output.source.nodeId, output.source.path, call.id);
|
||||
if (output.source.nodeId !== call.id) return undefined;
|
||||
if (output.destination === 'body') {
|
||||
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'body', outputField: '', setFormContentType: false };
|
||||
}
|
||||
if (output.destination.startsWith('body.')) {
|
||||
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'json-field', outputField: output.destination.slice(5), setFormContentType: false };
|
||||
}
|
||||
if (output.destination.toLowerCase().startsWith('header.')) {
|
||||
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'header', outputField: output.destination.slice(7), setFormContentType: false };
|
||||
}
|
||||
if (output.destination.startsWith('query.')) {
|
||||
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'query', outputField: output.destination.slice(6), setFormContentType: false };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function guidedOutputDescription(guide: GuidedTransformDraft): string {
|
||||
const field = guide.outputField.trim() || '待填写字段';
|
||||
if (guide.outputKind === 'body') return '替换整个线上 Body';
|
||||
if (guide.outputKind === 'json-field') return `写入 JSON 字段 ${field}`;
|
||||
if (guide.outputKind === 'form-field') return `组成表单字段 ${field}`;
|
||||
if (guide.outputKind === 'header') return `写入 Header ${field}`;
|
||||
return `写入 Query ${field}`;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BrowserTransformDirection, BrowserTransformPacket } from '@/types/models';
|
||||
import { assertTransformDirection, assertTransformRoute, executeTransformDirection, readTransformValue, wildcardUrlMatches } from './mapping';
|
||||
|
||||
function bodyBase64(value: unknown): string {
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return btoa(unescape(encodeURIComponent(text)));
|
||||
}
|
||||
|
||||
function decodeBody(value: string): string {
|
||||
return decodeURIComponent(escape(atob(value)));
|
||||
}
|
||||
|
||||
const packet: BrowserTransformPacket = {
|
||||
method: 'POST',
|
||||
url: 'https://portal.example.test/api/login?source=manual',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
bodyBase64: bodyBase64({ account: 'alice', password: 'plain' }),
|
||||
};
|
||||
|
||||
describe('browser transform Pipeline v2', () => {
|
||||
it('allows bounded literal values for generated headers', async () => {
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'literal', name: 'Content type', kind: 'builtin', operation: 'value.literal', inputs: [], options: { value: 'application/x-www-form-urlencoded' } },
|
||||
{ id: 'write', name: 'Write header', kind: 'output.write', source: { nodeId: 'literal' }, destination: 'header.Content-Type', encoding: 'text' },
|
||||
],
|
||||
};
|
||||
const result = await executeTransformDirection('profile-1', 'request', direction, packet, vi.fn());
|
||||
expect(result.setHeaders).toEqual([{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }]);
|
||||
});
|
||||
|
||||
it('resolves explicit paths without allowing prototype traversal', () => {
|
||||
expect(readTransformValue({ body: { user: { id: 7 } } }, 'body.user.id')).toBe(7);
|
||||
expect(() => readTransformValue({ body: {} }, 'body.__proto__.polluted')).toThrow('不允许');
|
||||
expect(() => readTransformValue({ body: {} }, 'body.missing')).toThrow('不存在');
|
||||
});
|
||||
|
||||
it('matches full URLs and paths with bounded wildcard syntax', () => {
|
||||
expect(wildcardUrlMatches('https://*.example.test/api/*', packet.url)).toBe(true);
|
||||
expect(wildcardUrlMatches('/api/*', packet.url)).toBe(true);
|
||||
expect(wildcardUrlMatches('/admin/*', packet.url)).toBe(false);
|
||||
expect(() => assertTransformRoute(['POST'], '/api/*', packet, 'https://portal.example.test')).not.toThrow();
|
||||
expect(() => assertTransformRoute(['POST'], '/api/*', { ...packet, url: 'https://outside.example.test/api/login' }, 'https://portal.example.test')).toThrow('不匹配页面来源');
|
||||
});
|
||||
|
||||
it('runs typed nodes and writes JSON, Header, and Query outputs', async () => {
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'password', name: 'Password', kind: 'context.read', path: 'body.password' },
|
||||
{ id: 'account', name: 'Account', kind: 'context.read', path: 'body.account' },
|
||||
{ id: 'cipher', name: 'Encrypt', kind: 'page.call', callableId: 'encrypt', arguments: [{ nodeId: 'password' }] },
|
||||
{ id: 'signature', name: 'Sign', kind: 'page.call', callableId: 'sign', arguments: [{ nodeId: 'account' }, { nodeId: 'cipher' }] },
|
||||
{ id: 'write-body', name: 'Write cipher', kind: 'output.write', source: { nodeId: 'cipher' }, destination: 'body.password', encoding: 'auto' },
|
||||
{ id: 'write-header', name: 'Write signature', kind: 'output.write', source: { nodeId: 'signature' }, destination: 'header.X-Sign', encoding: 'text' },
|
||||
{ id: 'write-query', name: 'Write mode', kind: 'output.write', source: { nodeId: 'account' }, destination: 'query.actor', encoding: 'text' },
|
||||
],
|
||||
};
|
||||
const invoke = vi.fn(async (callableId: string, args: unknown[]) => ({
|
||||
callableId,
|
||||
type: 'string',
|
||||
preview: callableId,
|
||||
value: callableId === 'encrypt' ? `cipher:${args[0]}` : `sig:${args.join(':')}`,
|
||||
durationMs: 1,
|
||||
}));
|
||||
|
||||
const result = await executeTransformDirection('profile-1', 'request', direction, packet, invoke);
|
||||
|
||||
expect(JSON.parse(decodeBody(result.bodyBase64))).toEqual({ account: 'alice', password: 'cipher:plain' });
|
||||
expect(result.setHeaders).toEqual([{ name: 'X-Sign', value: 'sig:alice:cipher:plain' }]);
|
||||
expect(new URL(result.url).searchParams.get('actor')).toBe('alice');
|
||||
expect(result.nodeDurations).toHaveLength(direction.nodes.length);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, 'sign', ['alice', 'cipher:plain']);
|
||||
});
|
||||
|
||||
it('executes white-listed builtins and preserves form serialization', async () => {
|
||||
const formPacket: BrowserTransformPacket = {
|
||||
...packet,
|
||||
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
||||
bodyBase64: bodyBase64('username=alice&password=plain'),
|
||||
};
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'password', name: 'Password', kind: 'context.read', path: 'body.password' },
|
||||
{ id: 'encoded', name: 'URL encode', kind: 'builtin', operation: 'url.encode', inputs: [{ nodeId: 'password' }] },
|
||||
{ id: 'write', name: 'Write', kind: 'output.write', source: { nodeId: 'encoded' }, destination: 'body.password', encoding: 'text' },
|
||||
],
|
||||
};
|
||||
const result = await executeTransformDirection('profile-1', 'request', direction, formPacket, vi.fn());
|
||||
expect(decodeBody(result.bodyBase64)).toBe('username=alice&password=plain');
|
||||
});
|
||||
|
||||
it('maps normalized page bytes to a binary body', async () => {
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'wire', name: 'Wire', kind: 'context.read', path: 'bodyBase64' },
|
||||
{ id: 'plain', name: 'Decrypt', kind: 'page.call', callableId: 'decrypt', arguments: [{ nodeId: 'wire' }] },
|
||||
{ id: 'write', name: 'Write', kind: 'output.write', source: { nodeId: 'plain' }, destination: 'body', encoding: 'auto' },
|
||||
],
|
||||
};
|
||||
const result = await executeTransformDirection('profile-1', 'response', direction, packet, async (callableId) => ({
|
||||
callableId,
|
||||
type: 'object',
|
||||
preview: 'bytes',
|
||||
value: { type: 'bytes', byteLength: 5, base64: btoa('hello') },
|
||||
durationMs: 1,
|
||||
}));
|
||||
expect(atob(result.bodyBase64)).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects forward references before invoking a page function', () => {
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'call', name: 'Call', kind: 'page.call', callableId: 'encrypt', arguments: [{ nodeId: 'future' }] },
|
||||
{ id: 'future', name: 'Future', kind: 'context.read', path: 'body' },
|
||||
{ id: 'write', name: 'Write', kind: 'output.write', source: { nodeId: 'call' }, destination: 'body', encoding: 'auto' },
|
||||
],
|
||||
};
|
||||
expect(() => assertTransformDirection(direction)).toThrow('尚未产生');
|
||||
});
|
||||
|
||||
it('rejects excessively deep context before invoking a page function', async () => {
|
||||
let body: Record<string, unknown> = {};
|
||||
const root = body;
|
||||
for (let index = 0; index < 70; index += 1) {
|
||||
const next: Record<string, unknown> = {};
|
||||
body.next = next;
|
||||
body = next;
|
||||
}
|
||||
const invoke = vi.fn();
|
||||
const direction: BrowserTransformDirection = {
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: 'input', name: 'Input', kind: 'context.read', path: 'body' },
|
||||
{ id: 'call', name: 'Encrypt', kind: 'page.call', callableId: 'encrypt', arguments: [{ nodeId: 'input' }] },
|
||||
{ id: 'write', name: 'Write', kind: 'output.write', source: { nodeId: 'call' }, destination: 'body', encoding: 'auto' },
|
||||
],
|
||||
};
|
||||
await expect(executeTransformDirection('profile-1', 'request', direction, { ...packet, bodyBase64: bodyBase64(root) }, invoke)).rejects.toThrow('嵌套超过 64 层');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import type {
|
||||
BrowserPageCallableExecution,
|
||||
BrowserTransformBuiltinOperation,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformExecution,
|
||||
BrowserTransformHeader,
|
||||
BrowserTransformNodeReference,
|
||||
BrowserTransformPacket,
|
||||
BrowserTransformValueEncoding,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_PATH_LENGTH = 512;
|
||||
const MAX_PATH_SEGMENTS = 64;
|
||||
const MAX_JSON_DEPTH = 64;
|
||||
const MAX_JSON_NODES = 100_000;
|
||||
const MAX_PIPELINE_NODES = 64;
|
||||
const BLOCKED_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const BUILTIN_OPERATIONS = new Set<BrowserTransformBuiltinOperation>([
|
||||
'value.literal',
|
||||
'json.stringify', 'json.parse', 'text.toString', 'url.encode', 'url.decode',
|
||||
'base64.encode', 'base64.decode', 'hex.encode', 'hex.decode',
|
||||
'object.pick', 'object.compose', 'form.compose',
|
||||
]);
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
type BodyFormat = 'empty' | 'json' | 'form' | 'text';
|
||||
|
||||
interface TransformContext {
|
||||
method?: string;
|
||||
url: string;
|
||||
statusCode?: number;
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string | string[]>;
|
||||
body: unknown;
|
||||
text: string;
|
||||
bodyBase64: string;
|
||||
}
|
||||
|
||||
export type PageCallableInvoker = (
|
||||
callableId: string,
|
||||
args: unknown[],
|
||||
) => Promise<BrowserPageCallableExecution>;
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
if (!value) return new Uint8Array();
|
||||
if (value.length > Math.ceil(MAX_BODY_BYTES / 3) * 4 + 8) {
|
||||
throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制');
|
||||
}
|
||||
let binary: string;
|
||||
try { binary = atob(value); } catch { throw new ExtensionError('transform_invalid_body', 'Base64 数据无效'); }
|
||||
if (binary.length > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制');
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64(bytes: Uint8Array): string {
|
||||
if (bytes.byteLength > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '页面转换结果超过 8 MiB 限制');
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 8_192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 8_192));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function headerRecord(headers: BrowserTransformHeader[]): Record<string, string> {
|
||||
const output = Object.create(null) as Record<string, string>;
|
||||
for (const header of headers) {
|
||||
output[header.name] = header.value;
|
||||
output[header.name.toLowerCase()] = header.value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function queryRecord(url: string): Record<string, string | string[]> {
|
||||
const output = Object.create(null) as Record<string, string | string[]>;
|
||||
try {
|
||||
for (const [key, value] of new URL(url).searchParams) {
|
||||
const previous = output[key];
|
||||
output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
||||
}
|
||||
} catch { /* URL is validated at the protocol boundary. */ }
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseForm(text: string): Record<string, string | string[]> {
|
||||
const output = Object.create(null) as Record<string, string | string[]>;
|
||||
for (const [key, value] of new URLSearchParams(text)) {
|
||||
const previous = output[key];
|
||||
output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseBody(bytes: Uint8Array, headers: Record<string, string>): { text: string; body: unknown; format: BodyFormat } {
|
||||
const text = decoder.decode(bytes);
|
||||
if (!text.trim()) return { text, body: '', format: 'empty' };
|
||||
try { return { text, body: JSON.parse(text) as unknown, format: 'json' }; } catch { /* Not JSON. */ }
|
||||
const contentType = headers['content-type']?.toLowerCase() || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
return { text, body: parseForm(text), format: 'form' };
|
||||
}
|
||||
return { text, body: text, format: 'text' };
|
||||
}
|
||||
|
||||
function pathSegments(path: string): string[] {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === '$') return [];
|
||||
if (trimmed.length > MAX_PATH_LENGTH) throw new ExtensionError('transform_invalid_path', '转换值路径过长');
|
||||
const normalized = trimmed.startsWith('$.') ? trimmed.slice(2) : trimmed;
|
||||
const segments = normalized.split('.').filter(Boolean);
|
||||
if (segments.length > MAX_PATH_SEGMENTS || segments.some((segment) => BLOCKED_PATH_SEGMENTS.has(segment))) {
|
||||
throw new ExtensionError('transform_invalid_path', `不允许的转换值路径: ${path}`);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function readTransformValue(input: unknown, path: string): unknown {
|
||||
let current = input;
|
||||
for (const segment of pathSegments(path)) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(segment);
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, segment)) {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
current = record[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
interface JsonCloneState { nodes: number; seen: WeakSet<object> }
|
||||
|
||||
function cloneJsonBody(value: unknown, state: JsonCloneState = { nodes: 0, seen: new WeakSet<object>() }, depth = 0): unknown {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > MAX_JSON_NODES) throw new ExtensionError('transform_value_too_large', '转换上下文包含过多节点');
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (depth >= MAX_JSON_DEPTH) throw new ExtensionError('transform_value_too_deep', `转换上下文嵌套超过 ${MAX_JSON_DEPTH} 层`);
|
||||
if (state.seen.has(value)) throw new ExtensionError('transform_value_invalid', '转换上下文包含循环引用');
|
||||
state.seen.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) return value.map((item) => cloneJsonBody(item, state, depth + 1));
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!BLOCKED_PATH_SEGMENTS.has(key)) output[key] = cloneJsonBody(item, state, depth + 1);
|
||||
}
|
||||
return output;
|
||||
} finally {
|
||||
state.seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function writeObjectPath(input: unknown, path: string, value: unknown): unknown {
|
||||
const segments = pathSegments(path);
|
||||
if (!segments.length) return value;
|
||||
const root = cloneJsonBody(input);
|
||||
if (!root || typeof root !== 'object') throw new ExtensionError('transform_output_invalid', `目标 ${path} 需要结构化 body`);
|
||||
let current = root as Record<string, unknown> | unknown[];
|
||||
segments.forEach((segment, index) => {
|
||||
const last = index === segments.length - 1;
|
||||
if (Array.isArray(current)) {
|
||||
const arrayIndex = Number(segment);
|
||||
if (!Number.isSafeInteger(arrayIndex) || arrayIndex < 0 || arrayIndex >= current.length) {
|
||||
throw new ExtensionError('transform_output_invalid', `目标数组路径不存在: ${path}`);
|
||||
}
|
||||
if (last) current[arrayIndex] = value;
|
||||
else {
|
||||
if (!current[arrayIndex] || typeof current[arrayIndex] !== 'object') current[arrayIndex] = /^\d+$/.test(segments[index + 1]) ? [] : {};
|
||||
current = current[arrayIndex] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (last) current[segment] = value;
|
||||
else {
|
||||
if (!current[segment] || typeof current[segment] !== 'object') current[segment] = /^\d+$/.test(segments[index + 1]) ? [] : {};
|
||||
current = current[segment] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function bytesValue(value: unknown, encoding: BrowserTransformValueEncoding = 'auto'): Uint8Array {
|
||||
if (value && typeof value === 'object' && (value as Record<string, unknown>).type === 'bytes'
|
||||
&& typeof (value as Record<string, unknown>).base64 === 'string') {
|
||||
return decodeBase64(String((value as Record<string, unknown>).base64));
|
||||
}
|
||||
if (encoding === 'base64') {
|
||||
if (typeof value !== 'string') throw new ExtensionError('transform_output_invalid', 'Base64 值必须是字符串或字节值');
|
||||
return decodeBase64(value);
|
||||
}
|
||||
if (encoding === 'json') return encoder.encode(JSON.stringify(value));
|
||||
if (typeof value === 'string') return encoder.encode(value);
|
||||
return encoder.encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
if (value && typeof value === 'object' && (value as Record<string, unknown>).type === 'bytes') return decoder.decode(bytesValue(value));
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function hexEncode(bytes: Uint8Array): string {
|
||||
let output = '';
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, '0');
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexDecode(value: string): Uint8Array {
|
||||
if (!/^(?:[0-9a-f]{2})*$/i.test(value)) throw new ExtensionError('transform_builtin_invalid', 'Hex 输入无效');
|
||||
const output = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < output.length; index += 1) output[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return output;
|
||||
}
|
||||
|
||||
function byteResult(bytes: Uint8Array): { type: 'bytes'; byteLength: number; base64: string } {
|
||||
return { type: 'bytes', byteLength: bytes.byteLength, base64: encodeBase64(bytes) };
|
||||
}
|
||||
|
||||
function optionStrings(options: Record<string, unknown> | undefined, key: string, max = 64): string[] {
|
||||
const value = options?.[key];
|
||||
if (!Array.isArray(value) || value.length > max || value.some((item) => typeof item !== 'string')) {
|
||||
throw new ExtensionError('transform_builtin_invalid', `内置操作需要字符串数组 options.${key}`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function executeBuiltin(operation: BrowserTransformBuiltinOperation, inputs: unknown[], options?: Record<string, unknown>): unknown {
|
||||
if (operation === 'value.literal') {
|
||||
if (inputs.length) throw new ExtensionError('transform_builtin_invalid', '固定值操作不接受输入');
|
||||
const value = options?.value;
|
||||
if (!['string', 'number', 'boolean'].includes(typeof value) && value !== null) {
|
||||
throw new ExtensionError('transform_builtin_invalid', '固定值只允许字符串、数字、布尔值或 null');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const one = () => {
|
||||
if (inputs.length !== 1) throw new ExtensionError('transform_builtin_invalid', `${operation} 需要 1 个输入`);
|
||||
return inputs[0];
|
||||
};
|
||||
if (operation === 'json.stringify') return JSON.stringify(one());
|
||||
if (operation === 'json.parse') {
|
||||
try { return JSON.parse(stringValue(one())) as unknown; } catch { throw new ExtensionError('transform_builtin_invalid', 'JSON 输入无效'); }
|
||||
}
|
||||
if (operation === 'text.toString') return stringValue(one());
|
||||
if (operation === 'url.encode') return encodeURIComponent(stringValue(one()));
|
||||
if (operation === 'url.decode') {
|
||||
try { return decodeURIComponent(stringValue(one())); } catch { throw new ExtensionError('transform_builtin_invalid', 'URL 编码输入无效'); }
|
||||
}
|
||||
if (operation === 'base64.encode') return encodeBase64(bytesValue(one()));
|
||||
if (operation === 'base64.decode') return byteResult(decodeBase64(stringValue(one())));
|
||||
if (operation === 'hex.encode') return hexEncode(bytesValue(one()));
|
||||
if (operation === 'hex.decode') return byteResult(hexDecode(stringValue(one())));
|
||||
if (operation === 'object.pick') {
|
||||
const source = one();
|
||||
const paths = optionStrings(options, 'paths');
|
||||
const keys = Array.isArray(options?.keys) ? optionStrings(options, 'keys') : paths.map((path) => path.split('.').at(-1) || path);
|
||||
if (paths.length !== keys.length) throw new ExtensionError('transform_builtin_invalid', 'object.pick 的 paths 与 keys 数量必须一致');
|
||||
return Object.fromEntries(paths.map((path, index) => [keys[index], cloneJsonBody(readTransformValue(source, path))]));
|
||||
}
|
||||
if (operation === 'object.compose') {
|
||||
const keys = optionStrings(options, 'keys');
|
||||
if (keys.length !== inputs.length || keys.some((key) => BLOCKED_PATH_SEGMENTS.has(key))) {
|
||||
throw new ExtensionError('transform_builtin_invalid', 'object.compose 的 keys 必须与输入一一对应');
|
||||
}
|
||||
return Object.fromEntries(keys.map((key, index) => [key, cloneJsonBody(inputs[index])]));
|
||||
}
|
||||
const keys = optionStrings(options, 'keys');
|
||||
if (keys.length !== inputs.length) throw new ExtensionError('transform_builtin_invalid', 'form.compose 的 keys 必须与输入一一对应');
|
||||
const form = new URLSearchParams();
|
||||
keys.forEach((key, index) => {
|
||||
const value = inputs[index];
|
||||
if (Array.isArray(value)) value.forEach((item) => form.append(key, stringValue(item)));
|
||||
else form.append(key, stringValue(value));
|
||||
});
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
function resolveReference(results: Map<string, unknown>, reference: BrowserTransformNodeReference): unknown {
|
||||
if (!results.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点引用不存在: ${reference.nodeId}`);
|
||||
const value = results.get(reference.nodeId);
|
||||
return reference.path ? readTransformValue(value, reference.path) : value;
|
||||
}
|
||||
|
||||
function validDestination(destination: string): boolean {
|
||||
if (destination === 'body') return true;
|
||||
if (destination.startsWith('body.')) { pathSegments(destination.slice(5)); return true; }
|
||||
if (destination.toLowerCase().startsWith('header.')) return Boolean(destination.slice(7)) && !/[\r\n:]/.test(destination.slice(7));
|
||||
if (destination.startsWith('query.')) return Boolean(destination.slice(6)) && !/[\r\n&#=]/.test(destination.slice(6));
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertTransformDirection(direction: BrowserTransformDirection): void {
|
||||
if (!direction.nodes.length || direction.nodes.length > MAX_PIPELINE_NODES) {
|
||||
throw new ExtensionError('transform_pipeline_empty', `转换 Pipeline 必须包含 1-${MAX_PIPELINE_NODES} 个节点`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let outputCount = 0;
|
||||
for (const node of direction.nodes) {
|
||||
if (!node.id.trim() || !node.name.trim() || seen.has(node.id)) throw new ExtensionError('transform_pipeline_invalid', `Pipeline 节点 ID 无效或重复: ${node.id}`);
|
||||
const references = node.kind === 'builtin' ? node.inputs
|
||||
: node.kind === 'page.call' ? node.arguments
|
||||
: node.kind === 'output.write' ? [node.source] : [];
|
||||
for (const reference of references) {
|
||||
if (!seen.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 引用了尚未产生的 ${reference.nodeId}`);
|
||||
if (reference.path) pathSegments(reference.path);
|
||||
}
|
||||
if (node.kind === 'context.read') pathSegments(node.path);
|
||||
if (node.kind === 'builtin' && !BUILTIN_OPERATIONS.has(node.operation)) throw new ExtensionError('transform_pipeline_invalid', `不支持的内置操作: ${node.operation}`);
|
||||
if (node.kind === 'page.call' && !node.callableId.trim()) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 未绑定页面函数`);
|
||||
if (node.kind === 'output.write') {
|
||||
outputCount += 1;
|
||||
if (!validDestination(node.destination.trim())) throw new ExtensionError('transform_output_invalid', `不支持的输出目标: ${node.destination}`);
|
||||
}
|
||||
seen.add(node.id);
|
||||
}
|
||||
if (!outputCount) throw new ExtensionError('transform_pipeline_empty', '转换 Pipeline 缺少 output.write 节点');
|
||||
}
|
||||
|
||||
export function wildcardUrlMatches(pattern: string, url: string): boolean {
|
||||
const value = pattern.trim();
|
||||
if (!value || value === '*') return true;
|
||||
const escaped = value.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
const matcher = new RegExp(`^${escaped}$`, 'i');
|
||||
if (matcher.test(url)) return true;
|
||||
try { return matcher.test(new URL(url).pathname); } catch { return false; }
|
||||
}
|
||||
|
||||
export function assertTransformRoute(methods: string[], urlPattern: string, packet: BrowserTransformPacket, pageOrigin?: string): void {
|
||||
if (methods.length && (!packet.method || !methods.includes(packet.method.toUpperCase()))) {
|
||||
throw new ExtensionError('transform_route_mismatch', `请求方法 ${packet.method || '(missing)'} 不匹配转换配置`);
|
||||
}
|
||||
const explicitOriginPattern = /^(?:https?|\*):\/\//i.test(urlPattern.trim());
|
||||
if (pageOrigin && !explicitOriginPattern) {
|
||||
let packetOrigin = '';
|
||||
try { packetOrigin = new URL(packet.url).origin; } catch { /* validated by protocol */ }
|
||||
if (packetOrigin !== pageOrigin) throw new ExtensionError('transform_route_mismatch', `URL 来源 ${packetOrigin || '(invalid)'} 不匹配页面来源 ${pageOrigin}`);
|
||||
}
|
||||
if (!wildcardUrlMatches(urlPattern, packet.url)) throw new ExtensionError('transform_route_mismatch', `URL 不匹配转换配置: ${packet.url}`);
|
||||
}
|
||||
|
||||
function serializeStructuredBody(value: unknown, format: BodyFormat): Uint8Array {
|
||||
if (format === 'form') {
|
||||
const form = new URLSearchParams();
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (Array.isArray(item)) item.forEach((entry) => form.append(key, stringValue(entry)));
|
||||
else form.append(key, stringValue(item));
|
||||
}
|
||||
return encoder.encode(form.toString());
|
||||
}
|
||||
return encoder.encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function executeTransformDirection(
|
||||
profileId: string,
|
||||
directionName: 'request' | 'response',
|
||||
direction: BrowserTransformDirection,
|
||||
packet: BrowserTransformPacket,
|
||||
invoke: PageCallableInvoker,
|
||||
): Promise<BrowserTransformExecution> {
|
||||
assertTransformDirection(direction);
|
||||
const started = performance.now();
|
||||
const rawBody = decodeBase64(packet.bodyBase64);
|
||||
const headers = headerRecord(packet.headers);
|
||||
const parsed = parseBody(rawBody, headers);
|
||||
const context: TransformContext = {
|
||||
method: packet.method?.toUpperCase(),
|
||||
url: packet.url,
|
||||
statusCode: packet.statusCode,
|
||||
headers,
|
||||
query: queryRecord(packet.url),
|
||||
body: parsed.body,
|
||||
text: parsed.text,
|
||||
bodyBase64: packet.bodyBase64,
|
||||
};
|
||||
const logicalInput = cloneJsonBody(context);
|
||||
const results = new Map<string, unknown>();
|
||||
const nodeDurations: Array<{ nodeId: string; durationMs: number }> = [];
|
||||
let outputBody = rawBody;
|
||||
let logicalBody = parsed.body;
|
||||
let outputUrl = packet.url;
|
||||
const setHeaders = new Map<string, BrowserTransformHeader>();
|
||||
const removeHeaders = new Map<string, string>();
|
||||
|
||||
for (const node of direction.nodes) {
|
||||
const nodeStarted = performance.now();
|
||||
if (node.kind === 'context.read') {
|
||||
results.set(node.id, cloneJsonBody(readTransformValue(context, node.path)));
|
||||
} else if (node.kind === 'builtin') {
|
||||
results.set(node.id, executeBuiltin(node.operation, node.inputs.map((reference) => resolveReference(results, reference)), node.options));
|
||||
} else if (node.kind === 'page.call') {
|
||||
const execution = await invoke(node.callableId, node.arguments.map((reference) => resolveReference(results, reference)));
|
||||
results.set(node.id, execution.value);
|
||||
} else {
|
||||
const value = resolveReference(results, node.source);
|
||||
const destination = node.destination.trim();
|
||||
if (destination === 'body') {
|
||||
outputBody = bytesValue(value, node.encoding);
|
||||
logicalBody = value;
|
||||
} else if (destination.startsWith('body.')) {
|
||||
logicalBody = writeObjectPath(logicalBody, destination.slice(5), value);
|
||||
outputBody = serializeStructuredBody(logicalBody, parsed.format === 'form' ? 'form' : 'json');
|
||||
} else if (destination.toLowerCase().startsWith('header.')) {
|
||||
const name = destination.slice(7).trim();
|
||||
const normalized = name.toLowerCase();
|
||||
const encoded = value === undefined || value === null ? undefined
|
||||
: node.encoding === 'base64' ? encodeBase64(bytesValue(value)) : stringValue(value);
|
||||
if (encoded === undefined) {
|
||||
removeHeaders.set(normalized, name);
|
||||
setHeaders.delete(normalized);
|
||||
} else {
|
||||
if (/[\r\n]/.test(encoded)) throw new ExtensionError('transform_output_invalid', `Header ${name} 的值包含换行`);
|
||||
setHeaders.set(normalized, { name, value: encoded });
|
||||
removeHeaders.delete(normalized);
|
||||
}
|
||||
} else if (destination.startsWith('query.')) {
|
||||
const url = new URL(outputUrl);
|
||||
const key = destination.slice(6);
|
||||
if (value === undefined || value === null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, stringValue(value));
|
||||
outputUrl = url.toString();
|
||||
}
|
||||
results.set(node.id, value);
|
||||
}
|
||||
nodeDurations.push({ nodeId: node.id, durationMs: Math.max(0, performance.now() - nodeStarted) });
|
||||
}
|
||||
|
||||
return {
|
||||
profileId,
|
||||
direction: directionName,
|
||||
url: outputUrl,
|
||||
bodyBase64: encodeBase64(outputBody),
|
||||
setHeaders: [...setHeaders.values()],
|
||||
removeHeaders: [...removeHeaders.values()],
|
||||
logicalInput,
|
||||
logicalOutput: cloneJsonBody({ url: outputUrl, body: logicalBody, nodes: Object.fromEntries(results) }),
|
||||
nodeDurations,
|
||||
durationMs: Math.max(0, performance.now() - started),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ActiveTabInfo,
|
||||
BrowserPageCallable,
|
||||
BrowserProfileInferenceCandidate,
|
||||
BrowserRecordingEvent,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformProfileInput,
|
||||
} from '@/types/models';
|
||||
import { compileGuidedTransform, defaultGuidedTransform, type GuidedTransformOutputKind } from './guided';
|
||||
|
||||
interface RequestRouteSource {
|
||||
url?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
function originOf(url?: string): string {
|
||||
try { return url ? new URL(url).origin : ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function routeOf(event?: RequestRouteSource, tab?: ActiveTabInfo): string {
|
||||
const value = event?.url || tab?.url;
|
||||
try { return value ? `*${new URL(value, tab?.url).pathname}` : '*'; } catch { return '*'; }
|
||||
}
|
||||
|
||||
function emptyDirection(enabled = false): BrowserTransformDirection {
|
||||
return { enabled, nodes: [] };
|
||||
}
|
||||
|
||||
function candidateOutput(candidate?: BrowserProfileInferenceCandidate): {
|
||||
outputKind?: GuidedTransformOutputKind;
|
||||
outputField?: string;
|
||||
} {
|
||||
const destination = candidate?.request.destination;
|
||||
const serialization = candidate?.request.serialization;
|
||||
if (!destination) return {};
|
||||
if (serialization === 'form-field') return { outputKind: 'form-field', outputField: destination.slice(5) };
|
||||
if (serialization === 'json-field') return { outputKind: 'json-field', outputField: destination.slice(5) };
|
||||
if (serialization === 'header') return { outputKind: 'header', outputField: destination.slice(7) };
|
||||
if (serialization === 'query') return { outputKind: 'query', outputField: destination.slice(6) };
|
||||
return { outputKind: 'body' };
|
||||
}
|
||||
|
||||
export function createBrowserTransformProfileInput(
|
||||
tab: ActiveTabInfo,
|
||||
event?: BrowserRecordingEvent,
|
||||
callable?: BrowserPageCallable,
|
||||
candidate?: BrowserProfileInferenceCandidate,
|
||||
): BrowserTransformProfileInput {
|
||||
const guide = defaultGuidedTransform(callable, candidateOutput(candidate));
|
||||
const routeEvent = candidate ? {
|
||||
url: candidate.request.url,
|
||||
method: candidate.request.method,
|
||||
} : event;
|
||||
return {
|
||||
name: routeEvent?.url ? `${routeEvent.method || 'HTTP'} ${routeOf(routeEvent, tab)} 明文网关` : `${tab.title || '当前页面'} 明文网关`,
|
||||
enabled: true,
|
||||
target: { tabId: tab.id, frameId: 0 },
|
||||
origin: originOf(tab.url),
|
||||
match: { methods: routeEvent?.method ? [routeEvent.method.toUpperCase()] : ['POST'], urlPattern: routeOf(routeEvent, tab) },
|
||||
request: callable ? compileGuidedTransform(guide, callable) : emptyDirection(true),
|
||||
response: emptyDirection(false),
|
||||
failMode: 'closed',
|
||||
maxConcurrency: 2,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const localStore = vi.hoisted(() => ({} as Record<string, unknown>));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: {
|
||||
storage: {
|
||||
local: {
|
||||
async get(key: string) {
|
||||
return key in localStore ? { [key]: structuredClone(localStore[key]) } : {};
|
||||
},
|
||||
async set(values: Record<string, unknown>) {
|
||||
Object.assign(localStore, structuredClone(values));
|
||||
},
|
||||
async remove(keys: string | string[]) {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) delete localStore[key];
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES,
|
||||
clearBrowserTransformReplayDraft,
|
||||
deleteBrowserTransformReplayDrafts,
|
||||
getBrowserTransformReplayDraft,
|
||||
saveBrowserTransformReplayDraft,
|
||||
} from './replay-draft';
|
||||
|
||||
const base = {
|
||||
profileId: 'profile-1',
|
||||
direction: 'request' as const,
|
||||
origin: 'https://example.test',
|
||||
method: 'POST',
|
||||
url: 'https://example.test/login',
|
||||
headers: '{"Content-Type":"application/json"}',
|
||||
body: '{"username":"admin","password":"123456"}',
|
||||
sample: { body: '{"username":"admin","password":"123456"}', label: '登录短时样本' },
|
||||
};
|
||||
|
||||
describe('browser transform replay drafts', () => {
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(localStore)) delete localStore[key];
|
||||
});
|
||||
|
||||
it('persists request and response replay inputs independently', async () => {
|
||||
expect((await saveBrowserTransformReplayDraft(base)).status).toBe('saved');
|
||||
expect((await saveBrowserTransformReplayDraft({
|
||||
...base,
|
||||
direction: 'response',
|
||||
method: 'GET',
|
||||
body: 'ciphertext',
|
||||
sample: undefined,
|
||||
})).status).toBe('saved');
|
||||
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'request', base.origin)).resolves.toMatchObject({
|
||||
method: 'POST',
|
||||
body: base.body,
|
||||
sample: base.sample,
|
||||
});
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'response', base.origin)).resolves.toMatchObject({
|
||||
method: 'GET',
|
||||
body: 'ciphertext',
|
||||
sample: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an older stored value instead of restoring stale data when the current draft is too large', async () => {
|
||||
await saveBrowserTransformReplayDraft(base);
|
||||
const result = await saveBrowserTransformReplayDraft({
|
||||
...base,
|
||||
body: 'x'.repeat(MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES + 1),
|
||||
sample: undefined,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: 'too-large', maxBytes: MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES });
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'request', base.origin)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not restore a draft under a different page origin', async () => {
|
||||
await saveBrowserTransformReplayDraft(base);
|
||||
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'request', 'https://other.test')).resolves.toBeUndefined();
|
||||
expect(Object.keys(localStore)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears one direction or every draft associated with a deleted profile', async () => {
|
||||
await saveBrowserTransformReplayDraft(base);
|
||||
await saveBrowserTransformReplayDraft({ ...base, direction: 'response' });
|
||||
await clearBrowserTransformReplayDraft(base.profileId, 'request');
|
||||
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'request', base.origin)).resolves.toBeUndefined();
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'response', base.origin)).resolves.toBeDefined();
|
||||
|
||||
await deleteBrowserTransformReplayDrafts(base.profileId);
|
||||
expect(Object.keys(localStore)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('serializes a clear behind an in-flight write for the same profile direction', async () => {
|
||||
const writing = saveBrowserTransformReplayDraft(base);
|
||||
const clearing = clearBrowserTransformReplayDraft(base.profileId, 'request');
|
||||
await Promise.all([writing, clearing]);
|
||||
|
||||
await expect(getBrowserTransformReplayDraft(base.profileId, 'request', base.origin)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BrowserTransformDirectionName } from '@/types/models';
|
||||
|
||||
/**
|
||||
* Replay bodies can contain credentials and tokens. Keep them behind per-draft
|
||||
* storage keys so they never become part of a portable transform profile or a
|
||||
* Bridge/RPC contract.
|
||||
*/
|
||||
const REPLAY_DRAFT_STORAGE_PREFIX = 'browser-transform-replay-draft.v1.';
|
||||
|
||||
export const MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES = 256 * 1024;
|
||||
|
||||
export interface BrowserTransformReplaySample {
|
||||
body: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BrowserTransformReplayDraftFields {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
sample?: BrowserTransformReplaySample;
|
||||
}
|
||||
|
||||
export interface BrowserTransformReplayDraft extends BrowserTransformReplayDraftFields {
|
||||
version: 1;
|
||||
profileId: string;
|
||||
direction: BrowserTransformDirectionName;
|
||||
origin: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type BrowserTransformReplayDraftInput = Omit<BrowserTransformReplayDraft, 'version' | 'updatedAt'>;
|
||||
|
||||
export type BrowserTransformReplayDraftSaveResult =
|
||||
| { status: 'saved'; draft: BrowserTransformReplayDraft; bytes: number }
|
||||
| { status: 'too-large'; bytes: number; maxBytes: number };
|
||||
|
||||
const mutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function enqueueMutation<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = mutationQueues.get(key) || Promise.resolve();
|
||||
const result = previous.then(task);
|
||||
const settled = result.then(() => undefined, () => undefined);
|
||||
mutationQueues.set(key, settled);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
if (mutationQueues.get(key) === settled) mutationQueues.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function replayDraftStorageKey(profileId: string, direction: BrowserTransformDirectionName): string {
|
||||
if (!profileId || profileId.length > 160) throw new Error('本地回放缺少有效的明文网关 ID');
|
||||
return `${REPLAY_DRAFT_STORAGE_PREFIX}${encodeURIComponent(profileId)}.${direction}`;
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function parseReplayDraft(value: unknown): BrowserTransformReplayDraft | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const candidate = value as Partial<BrowserTransformReplayDraft>;
|
||||
if (candidate.version !== 1
|
||||
|| !isString(candidate.profileId)
|
||||
|| (candidate.direction !== 'request' && candidate.direction !== 'response')
|
||||
|| !isString(candidate.origin)
|
||||
|| !isString(candidate.method)
|
||||
|| !isString(candidate.url)
|
||||
|| !isString(candidate.headers)
|
||||
|| !isString(candidate.body)
|
||||
|| typeof candidate.updatedAt !== 'number'
|
||||
|| !Number.isFinite(candidate.updatedAt)) return undefined;
|
||||
if (candidate.sample !== undefined && (!candidate.sample
|
||||
|| !isString(candidate.sample.body)
|
||||
|| !isString(candidate.sample.label))) return undefined;
|
||||
return candidate as BrowserTransformReplayDraft;
|
||||
}
|
||||
|
||||
function replayDraftBytes(value: BrowserTransformReplayDraftInput): number {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
export async function getBrowserTransformReplayDraft(
|
||||
profileId: string,
|
||||
direction: BrowserTransformDirectionName,
|
||||
origin: string,
|
||||
): Promise<BrowserTransformReplayDraft | undefined> {
|
||||
const key = replayDraftStorageKey(profileId, direction);
|
||||
await mutationQueues.get(key);
|
||||
const stored = (await browser.storage.local.get(key))[key];
|
||||
const draft = parseReplayDraft(stored);
|
||||
if (!draft || draft.profileId !== profileId || draft.direction !== direction || draft.origin !== origin) {
|
||||
if (stored !== undefined) await enqueueMutation(key, async () => {
|
||||
const latest = (await browser.storage.local.get(key))[key];
|
||||
const latestDraft = parseReplayDraft(latest);
|
||||
if (!latestDraft || latestDraft.profileId !== profileId
|
||||
|| latestDraft.direction !== direction || latestDraft.origin !== origin) {
|
||||
await browser.storage.local.remove(key);
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
export async function saveBrowserTransformReplayDraft(
|
||||
input: BrowserTransformReplayDraftInput,
|
||||
): Promise<BrowserTransformReplayDraftSaveResult> {
|
||||
const key = replayDraftStorageKey(input.profileId, input.direction);
|
||||
return enqueueMutation(key, async () => {
|
||||
const bytes = replayDraftBytes(input);
|
||||
if (bytes > MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES) {
|
||||
// Do not leave an older, now misleading draft behind when the current one
|
||||
// cannot be persisted in full.
|
||||
await browser.storage.local.remove(key);
|
||||
return { status: 'too-large', bytes, maxBytes: MAX_BROWSER_TRANSFORM_REPLAY_DRAFT_BYTES };
|
||||
}
|
||||
const draft: BrowserTransformReplayDraft = {
|
||||
...structuredClone(input),
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await browser.storage.local.set({ [key]: draft });
|
||||
return { status: 'saved', draft, bytes };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearBrowserTransformReplayDraft(
|
||||
profileId: string,
|
||||
direction: BrowserTransformDirectionName,
|
||||
): Promise<void> {
|
||||
const key = replayDraftStorageKey(profileId, direction);
|
||||
await enqueueMutation(key, () => browser.storage.local.remove(key));
|
||||
}
|
||||
|
||||
export async function deleteBrowserTransformReplayDrafts(profileId: string): Promise<void> {
|
||||
await Promise.all([
|
||||
clearBrowserTransformReplayDraft(profileId, 'request'),
|
||||
clearBrowserTransformReplayDraft(profileId, 'response'),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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 { browserTransformProfileSchema } from '@/protocol/transform';
|
||||
import type {
|
||||
BrowserTarget,
|
||||
BrowserTransformExecuteInput,
|
||||
BrowserTransformExecution,
|
||||
BrowserTransformPipelineNode,
|
||||
BrowserTransformProfile,
|
||||
BrowserTransformProfileInput,
|
||||
} 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';
|
||||
|
||||
const STORAGE_KEY = 'browser-transform-profiles.v2';
|
||||
const MAX_PROFILES = 64;
|
||||
const MAX_QUEUE_DEPTH = 128;
|
||||
|
||||
interface ProfileStore {
|
||||
profiles: BrowserTransformProfile[];
|
||||
}
|
||||
|
||||
const mutationQueues = new Map<string, Promise<void>>();
|
||||
const executionGates = new Map<string, TransformExecutionGate>();
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(input: BrowserTransformProfileInput, 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 },
|
||||
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 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 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 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];
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const target = await resolveDocumentTarget(profile.target);
|
||||
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);
|
||||
} finally {
|
||||
leave();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BrowserCookie, CookieRemoveInput } from '@/types/models';
|
||||
|
||||
export function cookieKey(cookie: BrowserCookie): string {
|
||||
return `${cookie.storeId}:${cookie.partitionKey?.topLevelSite || ''}:${cookie.domain}:${cookie.path}:${cookie.name}`;
|
||||
}
|
||||
|
||||
export function cookieRequestUrl(cookie: BrowserCookie): string {
|
||||
const domain = cookie.domain.replace(/^\./, '');
|
||||
const path = cookie.path.startsWith('/') ? cookie.path : `/${cookie.path}`;
|
||||
return `${cookie.secure ? 'https' : 'http'}://${domain}${path}`;
|
||||
}
|
||||
|
||||
export function cookieRemovalInput(cookie: BrowserCookie): CookieRemoveInput {
|
||||
return {
|
||||
url: cookieRequestUrl(cookie),
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
firstPartyDomain: cookie.firstPartyDomain,
|
||||
partitionKey: cookie.partitionKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, Braces, Bug, Check, ChevronDown, ChevronRight, CirclePause, Clock3, Code2, Copy,
|
||||
Crosshair, FileKey2, Fingerprint, Layers3, Play, RefreshCw, ShieldAlert, Sparkles, Trash2, Unplug, Variable, Webhook,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ActiveTabInfo, BrowserDeepCaptureFrame, BrowserDeepCaptureMatcher, BrowserDeepCaptureStatus,
|
||||
BrowserPageCallable, BrowserPageCallableExecution, BrowserPageCallableTransaction,
|
||||
BrowserProfileInferenceCandidate, BrowserRecordingEvent,
|
||||
} from '@/types/models';
|
||||
import './deep-capture-workspace.css';
|
||||
import { cryptoDeepCaptureMatcher } from '@/features/browser-crypto/model';
|
||||
import { capturedCallableSample, type CapturedCallableSample } from './callable-sample';
|
||||
|
||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
interface DeepCaptureWorkspaceProps {
|
||||
tab?: ActiveTabInfo;
|
||||
selectedEvent?: BrowserRecordingEvent;
|
||||
selectedCandidate?: BrowserProfileInferenceCandidate;
|
||||
autoArmRequest?: number;
|
||||
busy: boolean;
|
||||
run: RunTask;
|
||||
onPausedChange?: (paused: boolean) => void;
|
||||
onUseRecommendedCallable?: (
|
||||
candidate: BrowserProfileInferenceCandidate,
|
||||
callable: BrowserPageCallable,
|
||||
sample?: CapturedCallableSample,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<BrowserDeepCaptureStatus['state'], string> = {
|
||||
detached: '未附加',
|
||||
attached: '已附加',
|
||||
armed: '等待命中',
|
||||
paused: '现场已暂停',
|
||||
captured: '现场已释放',
|
||||
error: '需要处理',
|
||||
};
|
||||
|
||||
function eventMatcher(
|
||||
event?: BrowserRecordingEvent,
|
||||
candidate?: BrowserProfileInferenceCandidate,
|
||||
): BrowserDeepCaptureMatcher | undefined {
|
||||
if (!event) return undefined;
|
||||
const frameHints = candidate?.capturePlan?.matcherEventId === event.id
|
||||
? candidate.capturePlan.frameHints
|
||||
: undefined;
|
||||
const crypto = cryptoDeepCaptureMatcher(event);
|
||||
if (crypto) return { ...crypto, frameHints };
|
||||
if (['fetch', 'xhr', 'form'].includes(event.kind) && event.url) {
|
||||
return { kind: 'request', urlPattern: event.url, frameHints };
|
||||
}
|
||||
if (['beacon', 'worker', 'message'].includes(event.kind) && event.wrapperHandleId) {
|
||||
return {
|
||||
kind: 'boundary',
|
||||
eventKind: event.kind as 'beacon' | 'worker' | 'message',
|
||||
operation: event.operation,
|
||||
wrapperHandleId: event.wrapperHandleId,
|
||||
scriptUrl: event.scriptUrl,
|
||||
frameHints,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
if (!value) return '内联脚本';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.host}${url.pathname}`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function validIdentifier(value: string): boolean {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(value);
|
||||
}
|
||||
|
||||
function jsonPreview(value: unknown): string {
|
||||
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
||||
}
|
||||
|
||||
function requestTransaction(
|
||||
candidate: BrowserProfileInferenceCandidate,
|
||||
tab: ActiveTabInfo,
|
||||
): BrowserPageCallableTransaction {
|
||||
const expectedDestinations = candidate.sources
|
||||
.map((source) => source.destination)
|
||||
.filter((destination): destination is string => Boolean(destination));
|
||||
return {
|
||||
request: {
|
||||
method: candidate.request.method.toUpperCase(),
|
||||
url: new URL(candidate.request.url, tab.url).toString(),
|
||||
expectedDestinations,
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
|
||||
};
|
||||
}
|
||||
|
||||
const RISK_LABELS: Record<'network' | 'dom' | 'navigation' | 'storage', string> = {
|
||||
network: '包含网络发送',
|
||||
dom: '读取或修改 DOM',
|
||||
navigation: '可能触发导航',
|
||||
storage: '访问页面存储',
|
||||
};
|
||||
|
||||
const FRAME_SOURCE_LABELS: Record<BrowserDeepCaptureFrame['sourceKind'], string> = {
|
||||
page: '页面函数',
|
||||
'extension-hook': '插件 Hook',
|
||||
library: '依赖库',
|
||||
};
|
||||
|
||||
export function DeepCaptureWorkspace({
|
||||
tab, selectedEvent, selectedCandidate, autoArmRequest = 0, busy, run, onPausedChange, onUseRecommendedCallable,
|
||||
}: DeepCaptureWorkspaceProps) {
|
||||
const suggestedMatcher = useMemo(() => eventMatcher(selectedEvent, selectedCandidate), [selectedCandidate, selectedEvent]);
|
||||
const [matcherKind, setMatcherKind] = useState<'crypto' | 'boundary' | 'request'>(suggestedMatcher?.kind || 'request');
|
||||
const [adapterId, setAdapterId] = useState(suggestedMatcher?.kind === 'crypto' ? suggestedMatcher.adapterId : '');
|
||||
const [operation, setOperation] = useState(suggestedMatcher?.kind === 'crypto' || suggestedMatcher?.kind === 'boundary' ? suggestedMatcher.operation : '');
|
||||
const [wrapperHandleId, setWrapperHandleId] = useState(suggestedMatcher?.kind === 'crypto' || suggestedMatcher?.kind === 'boundary' ? suggestedMatcher.wrapperHandleId : '');
|
||||
const [boundaryEventKind, setBoundaryEventKind] = useState<'beacon' | 'worker' | 'message'>(
|
||||
suggestedMatcher?.kind === 'boundary' ? suggestedMatcher.eventKind : 'worker',
|
||||
);
|
||||
const [scriptUrl, setScriptUrl] = useState(suggestedMatcher?.kind === 'crypto' || suggestedMatcher?.kind === 'boundary' ? suggestedMatcher.scriptUrl || '' : '');
|
||||
const [urlPattern, setUrlPattern] = useState(suggestedMatcher?.kind === 'request' ? suggestedMatcher.urlPattern : '');
|
||||
const [status, setStatus] = useState<BrowserDeepCaptureStatus>();
|
||||
const [callables, setCallables] = useState<BrowserPageCallable[]>([]);
|
||||
const [selectedFrameId, setSelectedFrameId] = useState('');
|
||||
const [callableName, setCallableName] = useState('');
|
||||
const [functionExpression, setFunctionExpression] = useState('');
|
||||
const [selectedCallableId, setSelectedCallableId] = useState('');
|
||||
const [callableArgs, setCallableArgs] = useState('["test"]');
|
||||
const [execution, setExecution] = useState<BrowserPageCallableExecution>();
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [manualCaptureOpen, setManualCaptureOpen] = useState(true);
|
||||
const [expressionEditorOpen, setExpressionEditorOpen] = useState(false);
|
||||
const [expandedVariableKey, setExpandedVariableKey] = useState('');
|
||||
const statusRef = useRef<BrowserDeepCaptureStatus | undefined>(undefined);
|
||||
const handledAutoArmRequest = useRef(0);
|
||||
const handledAutoCapturePause = useRef(0);
|
||||
const automaticFlowRequested = useRef(false);
|
||||
|
||||
const target = status?.target || (tab ? { tabId: tab.id, frameId: 0 } : undefined);
|
||||
const paused = status?.state === 'paused' && Boolean(status.pause);
|
||||
|
||||
useEffect(() => { statusRef.current = status; }, [status]);
|
||||
useEffect(() => { onPausedChange?.(paused); }, [onPausedChange, paused]);
|
||||
useEffect(() => () => { onPausedChange?.(false); }, [onPausedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!suggestedMatcher || paused || status?.state === 'armed') return;
|
||||
setMatcherKind(suggestedMatcher.kind);
|
||||
if (suggestedMatcher.kind === 'crypto') {
|
||||
setAdapterId(suggestedMatcher.adapterId);
|
||||
setOperation(suggestedMatcher.operation);
|
||||
setWrapperHandleId(suggestedMatcher.wrapperHandleId);
|
||||
setScriptUrl(suggestedMatcher.scriptUrl || '');
|
||||
} else if (suggestedMatcher.kind === 'boundary') {
|
||||
setAdapterId('');
|
||||
setBoundaryEventKind(suggestedMatcher.eventKind);
|
||||
setOperation(suggestedMatcher.operation);
|
||||
setWrapperHandleId(suggestedMatcher.wrapperHandleId);
|
||||
setScriptUrl(suggestedMatcher.scriptUrl || '');
|
||||
} else {
|
||||
setAdapterId('');
|
||||
setOperation('');
|
||||
setWrapperHandleId('');
|
||||
setUrlPattern(suggestedMatcher.urlPattern);
|
||||
}
|
||||
}, [paused, status?.state, suggestedMatcher]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!tab) {
|
||||
setStatus(undefined);
|
||||
setCallables([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextStatus = await request('deep.capture.status', { tabId: tab.id, frameId: 0 });
|
||||
setStatus(nextStatus);
|
||||
const nextCallables = await request('callable.list', { tabId: tab.id, frameId: 0 }).catch(() => []);
|
||||
setCallables(nextCallables);
|
||||
setLoadError('');
|
||||
} catch (error) {
|
||||
setLoadError(errorMessage(error));
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoArmRequest || handledAutoArmRequest.current >= autoArmRequest || !tab || !suggestedMatcher || !status || busy) return;
|
||||
handledAutoArmRequest.current = autoArmRequest;
|
||||
if (status.state === 'armed' || status.state === 'paused') return;
|
||||
setMatcherKind(suggestedMatcher.kind);
|
||||
if (suggestedMatcher.kind === 'crypto') {
|
||||
setAdapterId(suggestedMatcher.adapterId);
|
||||
setOperation(suggestedMatcher.operation);
|
||||
setWrapperHandleId(suggestedMatcher.wrapperHandleId);
|
||||
setScriptUrl(suggestedMatcher.scriptUrl || '');
|
||||
} else if (suggestedMatcher.kind === 'boundary') {
|
||||
setBoundaryEventKind(suggestedMatcher.eventKind);
|
||||
setOperation(suggestedMatcher.operation);
|
||||
setWrapperHandleId(suggestedMatcher.wrapperHandleId);
|
||||
setScriptUrl(suggestedMatcher.scriptUrl || '');
|
||||
} else {
|
||||
setUrlPattern(suggestedMatcher.urlPattern);
|
||||
}
|
||||
void run(async () => {
|
||||
setExecution(undefined);
|
||||
const next = await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher: suggestedMatcher });
|
||||
automaticFlowRequested.current = true;
|
||||
setStatus(next);
|
||||
}, '自动分析已武装,请在目标页面重复刚才的操作');
|
||||
}, [autoArmRequest, busy, run, status, suggestedMatcher, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab || !['armed', 'paused', 'attached'].includes(status?.state || '')) return undefined;
|
||||
const interval = window.setInterval(() => void request('deep.capture.status', { tabId: tab.id, frameId: 0 })
|
||||
.then(setStatus).catch((error) => setLoadError(errorMessage(error))), status?.state === 'armed' ? 450 : 1_200);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [status?.state, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused || !target) return undefined;
|
||||
const keepalive = window.setInterval(() => void request('deep.capture.keepalive', target)
|
||||
.then(setStatus).catch((error) => setLoadError(errorMessage(error))), 10_000);
|
||||
return () => window.clearInterval(keepalive);
|
||||
}, [paused, target?.documentId, target?.frameId, target?.tabId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const current = statusRef.current;
|
||||
if (current && current.state !== 'detached') void request('deep.capture.detach', current.target).catch(() => undefined);
|
||||
}, [tab?.id]);
|
||||
|
||||
const frames = status?.pause?.frames || [];
|
||||
useEffect(() => {
|
||||
setSelectedFrameId((current) => frames.some((frame) => frame.id === current)
|
||||
? current
|
||||
: status?.pause?.automaticCapture?.frameId || status?.pause?.recommendedFrameId
|
||||
|| frames.find((frame) => frame.sourceKind === 'page')?.id || frames[0]?.id || '');
|
||||
}, [frames, status?.pause?.automaticCapture?.frameId, status?.pause?.recommendedFrameId]);
|
||||
|
||||
const selectedFrame = frames.find((frame) => frame.id === selectedFrameId);
|
||||
useEffect(() => {
|
||||
if (!selectedFrame) return;
|
||||
const functionName = selectedFrame.sourceKind === 'page' && selectedFrame.functionName !== '(anonymous)'
|
||||
? selectedFrame.functionName : '';
|
||||
setFunctionExpression('');
|
||||
setCallableName(functionName ? `${functionName} 业务封装` : '页面业务封装');
|
||||
setExpandedVariableKey('');
|
||||
}, [selectedFrame?.id, selectedFrame?.sourceKind, selectedFrame?.functionInspection?.resolved, selectedFrame?.functionInspection?.riskFlags.join(':')]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCallableId((current) => callables.some((callable) => callable.id === current)
|
||||
? current
|
||||
: callables.at(-1)?.id || '');
|
||||
}, [callables]);
|
||||
|
||||
const arm = () => run(async () => {
|
||||
if (!tab) throw new Error('请选择目标标签页');
|
||||
const frameHints = suggestedMatcher?.kind === matcherKind ? suggestedMatcher.frameHints : undefined;
|
||||
const matcher: BrowserDeepCaptureMatcher = matcherKind === 'crypto'
|
||||
? {
|
||||
kind: 'crypto',
|
||||
adapterId: adapterId.trim(),
|
||||
operation: operation.trim(),
|
||||
wrapperHandleId: wrapperHandleId.trim(),
|
||||
scriptUrl: scriptUrl.trim() || undefined,
|
||||
frameHints,
|
||||
}
|
||||
: matcherKind === 'boundary'
|
||||
? {
|
||||
kind: 'boundary',
|
||||
eventKind: boundaryEventKind,
|
||||
operation: operation.trim(),
|
||||
wrapperHandleId: wrapperHandleId.trim(),
|
||||
scriptUrl: scriptUrl.trim() || undefined,
|
||||
frameHints,
|
||||
}
|
||||
: { kind: 'request', urlPattern: urlPattern.trim(), frameHints };
|
||||
setExecution(undefined);
|
||||
automaticFlowRequested.current = false;
|
||||
setStatus(await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher }));
|
||||
}, '深度捕获已武装,请在目标页面重现一次操作');
|
||||
|
||||
const resume = () => run(async () => {
|
||||
if (!target) return;
|
||||
setStatus(await request('deep.capture.resume', target));
|
||||
}, '页面已恢复,调试会话已结束');
|
||||
|
||||
const detach = () => run(async () => {
|
||||
if (!target) return;
|
||||
setStatus(await request('deep.capture.detach', target));
|
||||
}, '深度捕获已结束');
|
||||
|
||||
const createCallable = (strategy: 'selected-frame' | 'expression') => run(async () => {
|
||||
if (!target || !selectedFrame) throw new Error('请选择业务调用帧');
|
||||
const callable = strategy === 'expression'
|
||||
? await request('callable.create', {
|
||||
...target, source: 'deep-capture', strategy, callFrameId: selectedFrame.id, name: callableName, functionExpression,
|
||||
})
|
||||
: await request('callable.create', {
|
||||
...target, source: 'deep-capture', strategy, callFrameId: selectedFrame.id, name: callableName,
|
||||
});
|
||||
setCallables((current) => [...current.filter((item) => item.id !== callable.id), callable]);
|
||||
setSelectedCallableId(callable.id);
|
||||
setStatus(await request('deep.capture.status', target));
|
||||
}, '业务函数已捕获,页面已恢复');
|
||||
|
||||
const recordedRecommendation = selectedCandidate?.status === 'ready'
|
||||
&& Boolean(selectedCandidate.source.callHandleId)
|
||||
? selectedCandidate : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused) return;
|
||||
setManualCaptureOpen(Boolean(
|
||||
!automaticFlowRequested.current
|
||||
||
|
||||
recordedRecommendation
|
||||
|| status?.pause?.automaticCapture?.state !== 'ready',
|
||||
));
|
||||
}, [paused, recordedRecommendation?.source.eventId, status?.pause?.automaticCapture?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpressionEditorOpen(false);
|
||||
}, [selectedFrame?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const pause = status?.pause;
|
||||
const automatic = pause?.automaticCapture;
|
||||
if (!automaticFlowRequested.current || !paused || !pause || pause.collecting || !target || !tab || selectedCandidate?.status !== 'capture-required'
|
||||
|| automatic?.state !== 'ready' || !automatic.frameId || handledAutoCapturePause.current === pause.pausedAt) return;
|
||||
const capturedPause = pause;
|
||||
const capturedFrameId = automatic.frameId;
|
||||
const captureStrategy = automatic.strategy || 'selected-frame';
|
||||
handledAutoCapturePause.current = pause.pausedAt;
|
||||
automaticFlowRequested.current = false;
|
||||
void run(async () => {
|
||||
const frame = capturedPause.frames.find((item) => item.id === capturedFrameId);
|
||||
const callableName = frame?.functionName && frame.functionName !== '(anonymous)'
|
||||
? `${frame.functionName} ${captureStrategy === 'request-transaction' ? '请求事务' : '业务封装'}`
|
||||
: undefined;
|
||||
const callable = captureStrategy === 'request-transaction'
|
||||
? await request('callable.create', {
|
||||
...target,
|
||||
source: 'deep-capture',
|
||||
strategy: 'request-transaction',
|
||||
callFrameId: capturedFrameId,
|
||||
transaction: requestTransaction(selectedCandidate, tab),
|
||||
...(callableName ? { name: callableName } : {}),
|
||||
})
|
||||
: await request('callable.create', {
|
||||
...target,
|
||||
source: 'deep-capture',
|
||||
strategy: 'selected-frame',
|
||||
callFrameId: capturedFrameId,
|
||||
...(callableName ? { name: callableName } : {}),
|
||||
});
|
||||
setCallables((current) => [...current.filter((item) => item.id !== callable.id), callable]);
|
||||
setSelectedCallableId(callable.id);
|
||||
setStatus(await request('deep.capture.status', target));
|
||||
await onUseRecommendedCallable?.(
|
||||
selectedCandidate,
|
||||
callable,
|
||||
captureStrategy === 'request-transaction' ? undefined : capturedCallableSample(frame),
|
||||
);
|
||||
}, captureStrategy === 'request-transaction'
|
||||
? '页面请求事务与明文网关已自动保存,真实发送将在回放时被截获'
|
||||
: '完整业务加密流程与明文网关已自动保存');
|
||||
}, [onUseRecommendedCallable, paused, run, selectedCandidate, status?.pause, tab, target]);
|
||||
|
||||
const useRecordedRecommendation = () => run(async () => {
|
||||
if (!target || !recordedRecommendation?.source.callHandleId) throw new Error('推荐调用已经失效');
|
||||
setStatus(await request('deep.capture.resume', target));
|
||||
let callable = callables.find((item) => item.provenance.eventId === recordedRecommendation.source.eventId);
|
||||
if (!callable) {
|
||||
callable = await request('callable.create', {
|
||||
...target,
|
||||
source: 'recording',
|
||||
callHandleId: recordedRecommendation.source.callHandleId,
|
||||
name: `${recordedRecommendation.source.crypto?.algorithm || recordedRecommendation.source.crypto?.operation || recordedRecommendation.source.operation} 页面函数`,
|
||||
});
|
||||
}
|
||||
const selected = callable;
|
||||
setCallables((current) => [...current.filter((item) => item.id !== selected.id), selected]);
|
||||
setSelectedCallableId(selected.id);
|
||||
await onUseRecommendedCallable?.(recordedRecommendation, selected);
|
||||
}, '已使用录制调用生成并保存明文网关');
|
||||
|
||||
const executeCallable = () => run(async () => {
|
||||
if (!target || !selectedCallableId) throw new Error('请选择页面函数');
|
||||
const args = JSON.parse(callableArgs) as unknown;
|
||||
if (!Array.isArray(args)) throw new Error('调用参数必须是 JSON 数组');
|
||||
setExecution(await request('callable.execute', { ...target, callableId: selectedCallableId, args }));
|
||||
}, '页面函数验证完成');
|
||||
|
||||
const deleteCallable = () => run(async () => {
|
||||
if (!target || !selectedCallableId) return;
|
||||
setCallables(await request('callable.delete', { ...target, callableId: selectedCallableId }));
|
||||
setExecution(undefined);
|
||||
}, '页面函数已删除');
|
||||
|
||||
const stages = [
|
||||
{ label: '目标', done: Boolean(suggestedMatcher || operation || urlPattern), current: status?.state === 'detached' },
|
||||
{ label: '等待命中', done: ['paused', 'captured'].includes(status?.state || ''), current: status?.state === 'armed' },
|
||||
{ label: '暂停现场', done: status?.state === 'captured' || callables.length > 0, current: paused },
|
||||
{ label: '页面函数', done: callables.length > 0, current: status?.state === 'captured' && callables.length === 0 },
|
||||
{ label: '验证', done: Boolean(execution), current: callables.length > 0 && !execution },
|
||||
];
|
||||
const automaticCapture = status?.pause?.automaticCapture;
|
||||
const selectedCaptureReady = selectedFrame?.sourceKind === 'page'
|
||||
&& selectedFrame.functionInspection?.resolved
|
||||
&& !selectedFrame.functionInspection.riskFlags.length;
|
||||
|
||||
return <div className="deep-capture">
|
||||
<div className="deep-capture__command">
|
||||
<div className="deep-capture__identity">
|
||||
<span className={`deep-status-dot state-${status?.state || 'detached'}`}><i /></span>
|
||||
<div><strong>深度捕获</strong><small>{STATUS_LABELS[status?.state || 'detached']}{status?.matcher && status.matcher.kind !== 'request' ? ` · ${status.matcher.operation}` : ''}</small></div>
|
||||
</div>
|
||||
<ol className="deep-stage-strip">
|
||||
{stages.map((stage, index) => <li key={stage.label} className={`${stage.done ? 'is-done' : ''} ${stage.current ? 'is-current' : ''}`}>
|
||||
<span>{stage.done ? <Check size={11} /> : index + 1}</span><em>{stage.label}</em>{index < stages.length - 1 && <ChevronRight size={12} />}
|
||||
</li>)}
|
||||
</ol>
|
||||
<div className="deep-capture__command-actions">
|
||||
<Button size="icon" variant="ghost" title="刷新深度捕获" aria-label="刷新深度捕获" disabled={!tab} onClick={() => void load()}><RefreshCw size={15} /></Button>
|
||||
{status && status.state !== 'detached' && <Button size="icon" variant="ghost" title="结束并释放调试会话" aria-label="结束并释放调试会话" disabled={busy} onClick={() => void detach()}><Unplug size={15} /></Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="deep-message is-error"><AlertTriangle size={15} /><span>{loadError}</span></div>}
|
||||
{status?.error && <div className="deep-message is-warning"><ShieldAlert size={15} /><span>{status.error}</span></div>}
|
||||
|
||||
{!paused ? <>
|
||||
<section className="deep-arm-panel">
|
||||
<div className="deep-arm-panel__mode" role="group" aria-label="捕获目标类型">
|
||||
<button className={matcherKind === 'crypto' ? 'is-selected' : ''} disabled={!adapterId || !wrapperHandleId} title={adapterId && wrapperHandleId ? '捕获选中的录制调用' : '请先在录制中选择一个密码调用'} onClick={() => setMatcherKind('crypto')}><Fingerprint size={15} /><span>加密调用</span></button>
|
||||
<button className={matcherKind === 'boundary' ? 'is-selected' : ''} disabled={Boolean(adapterId) || !wrapperHandleId} title={!adapterId && wrapperHandleId ? '捕获选中的页面通信边界' : '请先在录制中选择 Beacon、Worker 或 MessagePort 调用'} onClick={() => setMatcherKind('boundary')}><Webhook size={15} /><span>消息边界</span></button>
|
||||
<button className={matcherKind === 'request' ? 'is-selected' : ''} onClick={() => setMatcherKind('request')}><Crosshair size={15} /><span>目标请求</span></button>
|
||||
</div>
|
||||
<div className="deep-arm-panel__fields">
|
||||
{matcherKind === 'crypto' ? <>
|
||||
<label><span>密码调用</span><input value={adapterId && operation ? `${adapterId} · ${operation}` : ''} readOnly placeholder="请从录制结果选择密码调用" /></label>
|
||||
<label><span>脚本过滤</span><input value={scriptUrl} onChange={(event) => setScriptUrl(event.target.value)} placeholder="可选" /></label>
|
||||
</> : matcherKind === 'boundary' ? <>
|
||||
<label><span>通信边界</span><input value={`${boundaryEventKind} · ${operation}`} readOnly placeholder="请从录制结果选择消息调用" /></label>
|
||||
<label><span>脚本过滤</span><input value={scriptUrl} onChange={(event) => setScriptUrl(event.target.value)} placeholder="可选" /></label>
|
||||
</> : <label className="is-wide"><span>URL 片段</span><input value={urlPattern} onChange={(event) => setUrlPattern(event.target.value)} placeholder="/api/login" /></label>}
|
||||
</div>
|
||||
<Button variant="primary" disabled={busy || !tab || (matcherKind === 'crypto'
|
||||
? !adapterId.trim() || !operation.trim() || !wrapperHandleId.trim()
|
||||
: matcherKind === 'boundary' ? !operation.trim() || !wrapperHandleId.trim() : !urlPattern.trim())} onClick={() => void arm()}><Bug size={15} />武装下一次命中</Button>
|
||||
</section>
|
||||
|
||||
{status?.state === 'armed' && <div className="deep-waiting"><span><CirclePause size={17} /></span><div><strong>等待目标页面命中</strong><small>{status.matcher?.kind === 'request' ? status.matcher.urlPattern : status.matcher?.operation}</small></div><i /></div>}
|
||||
|
||||
<section className="deep-adapter-lab">
|
||||
<div className="deep-adapter-list">
|
||||
<header><div><Layers3 size={15} /><strong>当前文档页面函数</strong></div><span>{callables.length}</span></header>
|
||||
{!callables.length ? <div className="deep-column-empty"><Code2 size={20} /><span>尚未捕获业务函数</span></div> : callables.map((callable) => <button key={callable.id} className={callable.id === selectedCallableId ? 'is-selected' : ''} onClick={() => { setSelectedCallableId(callable.id); setExecution(undefined); }}>
|
||||
<span><strong>{callable.name}</strong><small>{callable.kind === 'request-transaction' ? '请求事务' : callable.provenance.functionName || callable.operation} · {compactUrl(callable.provenance.sourceUrl || '')}{callable.provenance.lineNumber ? `:${callable.provenance.lineNumber}` : ''}</small></span><ChevronRight size={14} />
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="deep-adapter-runner">
|
||||
<header><div><Play size={15} /><strong>调用验证</strong></div>{execution && <span>{execution.durationMs.toFixed(1)} ms</span>}</header>
|
||||
<label><span>参数 · JSON 数组</span><textarea rows={5} value={callableArgs} onChange={(event) => setCallableArgs(event.target.value)} spellCheck={false} /></label>
|
||||
<div className="deep-adapter-runner__actions"><Button size="icon" variant="ghost" title="删除页面函数" aria-label="删除页面函数" disabled={!selectedCallableId || busy} onClick={() => void deleteCallable()}><Trash2 size={14} /></Button><Button variant="primary" disabled={!selectedCallableId || busy} onClick={() => void executeCallable()}><Play size={14} />运行</Button></div>
|
||||
{execution && <div className="deep-execution-result"><div><strong>{execution.type}</strong><span>{execution.callableId.slice(0, 8)}</span></div><pre>{jsonPreview(execution.value)}</pre></div>}
|
||||
</div>
|
||||
</section>
|
||||
</> : <section className="deep-paused-workbench">
|
||||
<div className="deep-paused-banner"><div><CirclePause size={16} /><strong>页面已暂停</strong><span>剩余 {Math.max(0, Math.ceil(((status?.pause?.deadline || Date.now()) - Date.now()) / 1_000))} 秒</span></div><Button variant="ghost" disabled={busy} onClick={() => void resume()}><Play size={14} />恢复并结束调试</Button></div>
|
||||
{recordedRecommendation && <section className="deep-recorded-recommendation">
|
||||
<span><Sparkles size={17} /></span>
|
||||
<div><small>推荐方案</small><strong>直接复用已录制的 {recordedRecommendation.source.crypto?.algorithm || recordedRecommendation.source.crypto?.operation || recordedRecommendation.source.operation}</strong><p>已证明输出进入 {recordedRecommendation.request.destination}。原函数、receiver 与固定参数已由页面调用句柄保留,不需要填写函数表达式。</p><div><i>无额外网络调用</i><i>使用真实页面环境</i><i>自动生成 Profile</i></div></div>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void useRecordedRecommendation()}><FileKey2 size={14} />使用推荐方案</Button>
|
||||
</section>}
|
||||
{!recordedRecommendation && automaticCapture && <section className={`deep-auto-resolution is-${automaticCapture.state}`} role="status">
|
||||
<span>{automaticCapture.state === 'ready' ? <Sparkles size={17} /> : automaticCapture.state === 'ambiguous' ? <Layers3 size={17} /> : <ShieldAlert size={17} />}</span>
|
||||
<div>
|
||||
<small>{automaticCapture.state === 'ready' ? '自动业务边界' : automaticCapture.state === 'ambiguous' ? '需要确认' : automaticCapture.state === 'blocked' ? '安全阻止' : '需要高级定位'}</small>
|
||||
<strong>{automaticCapture.state === 'ready'
|
||||
? automaticCapture.strategy === 'request-transaction'
|
||||
? '已定位页面发送流程,正在建立截获式回放'
|
||||
: '已定位完整页面业务函数,正在保存并生成明文网关'
|
||||
: automaticCapture.state === 'ambiguous'
|
||||
? '多个页面函数同样接近真实加密边界'
|
||||
: automaticCapture.state === 'blocked'
|
||||
? '最接近的函数不能作为安全转换函数'
|
||||
: '当前栈帧无法唯一还原为函数对象'}</strong>
|
||||
<p>{automaticCapture.reason}</p>
|
||||
</div>
|
||||
{automaticCapture.state === 'ready' && <i><span />自动处理中</i>}
|
||||
</section>}
|
||||
<details className="deep-manual-capture" open={manualCaptureOpen} onToggle={(event) => setManualCaptureOpen(event.currentTarget.open)}>
|
||||
<summary><span><Braces size={14} /><strong>{recordedRecommendation || automaticCapture?.state === 'ready' ? '高级:检查调用栈与其他候选' : '确认页面业务边界'}</strong></span><em>{automaticCapture?.state === 'ambiguous' ? '请选择实际组装报文的函数' : '插件已排除 Hook、依赖与明显副作用'}</em></summary>
|
||||
<div className="deep-paused-grid">
|
||||
<aside className="deep-stack">
|
||||
<header><Bug size={14} /><strong>调用栈</strong><span>{frames.length}</span></header>
|
||||
<div>{frames.map((frame) => <button key={frame.id} className={`${frame.id === selectedFrameId ? 'is-selected' : ''} ${frame.sourceKind !== 'page' ? 'is-library' : ''} source-${frame.sourceKind}`} onClick={() => setSelectedFrameId(frame.id)}>
|
||||
<span className="deep-frame-index">{frame.index}</span><span><strong>{frame.functionName}</strong><small>{compactUrl(frame.url)}:{frame.lineNumber}</small></span><span className="deep-frame-badges">{status?.pause?.recommendedFrameId === frame.id ? <em className="is-clean" title={frame.businessReasons?.join(' · ')}>推荐 · {frame.businessScore}</em> : null}<em className={`source-${frame.sourceKind}`}>{FRAME_SOURCE_LABELS[frame.sourceKind]}</em>{frame.functionInspection?.riskFlags.length ? <em className="has-risk" title={frame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join('、')}>有副作用</em> : frame.functionInspection?.resolved && frame.sourceKind === 'page' ? <em className="is-clean">可评估</em> : null}</span>
|
||||
</button>)}</div>
|
||||
</aside>
|
||||
<section className="deep-scopes">
|
||||
<header><Variable size={14} /><strong>作用域</strong><span>{selectedFrame?.scopes.reduce((count, scope) => count + scope.variables.length, 0) || 0}</span></header>
|
||||
<div>{selectedFrame?.scopes.map((scope, scopeIndex) => <section key={`${scope.type}:${scopeIndex}`}>
|
||||
<h4><span>{scope.type}</span><small>{scope.name || `${scope.variables.length} 个变量`}</small></h4>
|
||||
{scope.variables.map((variable) => {
|
||||
const variableKey = `${selectedFrame.id}:${scopeIndex}:${variable.name}`;
|
||||
const expanded = expandedVariableKey === variableKey;
|
||||
const detail = variable.detail || variable.preview;
|
||||
return <div className={`deep-scope-variable ${expanded ? 'is-expanded' : ''}`} key={`${scopeIndex}:${variable.name}`}>
|
||||
<button type="button" aria-expanded={expanded} onClick={() => setExpandedVariableKey(expanded ? '' : variableKey)}>
|
||||
<code>{variable.name}</code><span>{variable.preview}</span><em>{variable.subtype || variable.type}</em><ChevronDown size={12} />
|
||||
</button>
|
||||
{expanded && <div className="deep-scope-variable__detail"><header><span>{variable.type === 'function' ? '函数源码' : '值预览'}{variable.detailTruncated ? ' · 已截断' : ''}</span><div><Button size="icon" variant="ghost" aria-label={`复制 ${variable.name}`} title="复制内容" onClick={() => void navigator.clipboard.writeText(detail)}><Copy size={12} /></Button>{variable.type === 'function' && validIdentifier(variable.name) && <Button size="sm" variant="ghost" onClick={() => { setFunctionExpression(variable.name); setCallableName(`${variable.name} 业务封装`); setExpressionEditorOpen(true); }}>高级引用</Button>}</div></header><pre>{detail}</pre></div>}
|
||||
</div>;
|
||||
})}
|
||||
</section>) || <div className="deep-column-empty">没有可读作用域</div>}</div>
|
||||
</section>
|
||||
<aside className="deep-adapter-editor">
|
||||
<header><Braces size={14} /><strong>函数评估</strong></header>
|
||||
<div className="deep-frame-summary"><strong>{selectedFrame?.functionName || '未选择调用帧'}</strong><small>{selectedFrame ? `${compactUrl(selectedFrame.url)}:${selectedFrame.lineNumber}:${selectedFrame.columnNumber}` : ''}</small><span>{selectedFrame?.thisPreview || ''}</span></div>
|
||||
{selectedFrame?.sourceKind === 'extension-hook' ? <div className="deep-function-assessment is-hook"><Bug size={15} /><span><strong>这是插件注入的观测帧</strong><small>它只负责记录或设置断点,不是页面业务代码。请选择调用栈中标记为“页面函数”的下游帧。</small></span></div> : selectedFrame?.functionInspection?.resolved ? <div className={`deep-function-assessment ${selectedFrame.functionInspection.riskFlags.length ? 'has-risk' : 'is-clean'}`}>
|
||||
{selectedFrame.functionInspection.riskFlags.length ? <><ShieldAlert size={15} /><span><strong>已阻止注册为可回放函数</strong><small>{selectedFrame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join(' · ')}。直接调用可能改变页面或发送真实请求。</small></span></> : <><Check size={15} /><span><strong>函数对象已自动解析</strong><small>{selectedFrame.functionInspection.parameterCount || 0} 个参数 · {selectedFrame.functionInspection.resolution === 'receiver-method' ? '页面方法' : selectedFrame.functionInspection.resolution === 'scope-binding' ? '闭包绑定' : '当前栈帧'} · 未发现明显副作用</small></span></>}
|
||||
</div> : <div className="deep-function-assessment has-risk"><AlertTriangle size={15} /><span><strong>无法唯一解析当前函数</strong><small>{selectedFrame?.functionInspection?.candidateCount ? `发现 ${selectedFrame.functionInspection.candidateCount} 个同分候选;` : ''}请选择其他业务栈帧,或在高级模式中指定闭包变量。</small></span></div>}
|
||||
<div className="deep-adapter-editor__primary"><Button variant="primary" disabled={busy || !selectedCaptureReady || !callableName.trim()} onClick={() => void createCallable('selected-frame')}><Sparkles size={14} />捕获所选业务函数</Button><small>{selectedCaptureReady ? '函数引用、receiver、来源位置与参数数量由暂停现场自动保存' : '只有唯一解析且无明显副作用的页面函数可以保存'}</small></div>
|
||||
<details className="deep-expression-editor" open={expressionEditorOpen} onToggle={(event) => setExpressionEditorOpen(event.currentTarget.open)}>
|
||||
<summary>高级:函数引用表达式</summary>
|
||||
<p>仅用于匿名闭包或特殊打包产物。表达式必须在所选暂停帧中返回 Function,且仍会经过副作用门控。</p>
|
||||
<label><span>页面函数名称</span><input value={callableName} onChange={(event) => setCallableName(event.target.value)} /></label>
|
||||
<label><span>函数引用</span><textarea rows={5} value={functionExpression} onChange={(event) => setFunctionExpression(event.target.value)} spellCheck={false} placeholder="buildLoginEnvelope" /></label>
|
||||
<div className="deep-adapter-editor__actions"><Button variant="ghost" disabled={busy || !selectedFrame || selectedFrame.sourceKind !== 'page' || Boolean(selectedFrame.functionInspection?.riskFlags.length) || !callableName.trim() || !functionExpression.trim()} onClick={() => void createCallable('expression')}><Code2 size={14} />验证表达式并捕获</Button></div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</details>
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserDeepCaptureFrame } from '@/types/models';
|
||||
import { rankBusinessFrames } from './business-frame-ranker';
|
||||
|
||||
function frame(id: string, overrides: Partial<BrowserDeepCaptureFrame> = {}): BrowserDeepCaptureFrame {
|
||||
return {
|
||||
id,
|
||||
index: 0,
|
||||
functionName: 'functionName',
|
||||
scriptId: id,
|
||||
url: 'https://example.test/app.js',
|
||||
lineNumber: 1,
|
||||
columnNumber: 1,
|
||||
scopes: [],
|
||||
thisPreview: 'Window',
|
||||
sourceKind: 'page',
|
||||
libraryFrame: false,
|
||||
functionInspection: { resolved: true, parameterCount: 1, riskFlags: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('business frame ranker', () => {
|
||||
it('prefers a safe page closure over recorder and dependency frames', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('hook', { sourceKind: 'extension-hook', libraryFrame: true, functionName: 'recordedFetch' }),
|
||||
frame('library', { index: 1, sourceKind: 'library', libraryFrame: true, functionName: 'encrypt' }),
|
||||
frame('business', {
|
||||
index: 2,
|
||||
functionName: 'buildLoginEnvelope',
|
||||
scopes: [{ type: 'closure', variables: [{ name: 'payload', type: 'object', preview: 'Object' }] }],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.recommendedFrameId).toBe('business');
|
||||
expect(result.automaticCapture).toMatchObject({ state: 'ready', frameId: 'business' });
|
||||
expect(result.frames.find((item) => item.id === 'hook')?.businessScore).toBe(0);
|
||||
expect(result.frames.find((item) => item.id === 'business')?.businessReasons).toContain('具有可分析参数或闭包现场');
|
||||
});
|
||||
|
||||
it('penalizes a page function that would resend the real request', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('network', { functionName: 'submitLogin', functionInspection: { resolved: true, riskFlags: ['network'] } }),
|
||||
frame('pure', { index: 2, functionName: 'buildEnvelope' }),
|
||||
]);
|
||||
expect(result.recommendedFrameId).toBe('pure');
|
||||
expect(result.frames[0].businessScore).toBeLessThan(result.frames[1].businessScore || 0);
|
||||
});
|
||||
|
||||
it('uses a common recorded-stack ancestor without hard-coded page names', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('near', { index: 1, functionName: '_0x91' }),
|
||||
frame('common', { index: 3, functionName: '_0x47' }),
|
||||
], [{
|
||||
functionName: '_0x47', url: 'https://example.test/app.js', support: 3, averageDepth: 1,
|
||||
}]);
|
||||
|
||||
expect(result.recommendedFrameId).toBe('common');
|
||||
expect(result.frames[1].businessReasons).toContain('3 个密码调用的共同业务祖先');
|
||||
});
|
||||
|
||||
it('captures the nearest side-effecting common ancestor as a request transaction instead of onclick', () => {
|
||||
const hints = [
|
||||
{ functionName: 'sendDataAesRsa', url: 'https://example.test/app.js', support: 3, averageDepth: 1 },
|
||||
{ functionName: 'onclick', url: 'https://example.test/', support: 3, averageDepth: 2 },
|
||||
];
|
||||
const result = rankBusinessFrames([
|
||||
frame('sender', {
|
||||
index: 2,
|
||||
functionName: 'sendDataAesRsa',
|
||||
functionInspection: {
|
||||
resolved: true,
|
||||
parameterCount: 1,
|
||||
parameterNames: ['url'],
|
||||
riskFlags: ['network', 'dom', 'navigation'],
|
||||
},
|
||||
}),
|
||||
frame('onclick', {
|
||||
index: 3,
|
||||
functionName: 'onclick',
|
||||
url: 'https://example.test/',
|
||||
thisPreview: 'HTMLButtonElement',
|
||||
functionInspection: {
|
||||
resolved: true,
|
||||
parameterCount: 1,
|
||||
parameterNames: ['event'],
|
||||
riskFlags: [],
|
||||
},
|
||||
}),
|
||||
], hints);
|
||||
|
||||
expect(result.recommendedFrameId).toBe('sender');
|
||||
expect(result.automaticCapture).toMatchObject({
|
||||
state: 'ready',
|
||||
strategy: 'request-transaction',
|
||||
frameId: 'sender',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not guess between equally supported safe page functions', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('first', { index: 2, functionName: '_0x1' }),
|
||||
frame('second', { index: 2, functionName: '_0x2' }),
|
||||
]);
|
||||
expect(result.automaticCapture.state).toBe('ambiguous');
|
||||
});
|
||||
|
||||
it('does not ignore recorded stack hints to capture an unrelated safe function', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('unrelated', { index: 1, functionName: 'differentFunction' }),
|
||||
], [{ functionName: 'expectedEnvelope', url: 'https://example.test/app.js', support: 2, averageDepth: 1 }]);
|
||||
expect(result.automaticCapture.state).toBe('unavailable');
|
||||
expect(result.automaticCapture.reason).toContain('没有与暂停现场唯一对应');
|
||||
});
|
||||
|
||||
it('blocks automatic capture when the only resolved business function has side effects', () => {
|
||||
const result = rankBusinessFrames([
|
||||
frame('sender', { functionName: 'submit', functionInspection: { resolved: true, riskFlags: ['network'] } }),
|
||||
]);
|
||||
expect(result.automaticCapture).toMatchObject({ state: 'blocked', frameId: 'sender' });
|
||||
});
|
||||
|
||||
it('is deterministic when candidates have the same evidence', () => {
|
||||
const first = frame('a', { index: 2, functionName: '_0x1' });
|
||||
const second = frame('b', { index: 2, functionName: '_0x2' });
|
||||
expect(rankBusinessFrames([second, first]).recommendedFrameId).toBe('a');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { BrowserBusinessFrameHint, BrowserDeepCaptureFrame, BrowserDeepCapturePause } from '@/types/models';
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
||||
const EVENT_HANDLER_NAME = /^on(?:abort|beforeinput|blur|change|click|close|contextmenu|dblclick|error|focus|input|keydown|keypress|keyup|load|mousedown|mouseenter|mouseleave|mousemove|mouseout|mouseover|mouseup|pointer|reset|resize|scroll|submit|touch|unload|wheel)/i;
|
||||
const TRANSACTION_RISKS = new Set(['network', 'dom', 'navigation']);
|
||||
|
||||
export interface RankedBusinessFrames {
|
||||
frames: BrowserDeepCaptureFrame[];
|
||||
recommendedFrameId?: string;
|
||||
automaticCapture: NonNullable<BrowserDeepCapturePause['automaticCapture']>;
|
||||
}
|
||||
|
||||
function comparableUrl(value?: string): string {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0];
|
||||
}
|
||||
}
|
||||
|
||||
function nameMatches(frameName: string, hintName: string): boolean {
|
||||
return frameName === hintName || frameName.endsWith(`.${hintName}`) || hintName.endsWith(`.${frameName}`);
|
||||
}
|
||||
|
||||
function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFrameHint[]): BrowserBusinessFrameHint | undefined {
|
||||
return hints.find((hint) => nameMatches(frame.functionName, hint.functionName)
|
||||
&& (!hint.url || comparableUrl(frame.url) === comparableUrl(hint.url)));
|
||||
}
|
||||
|
||||
function isEventHandler(frame: BrowserDeepCaptureFrame): boolean {
|
||||
if (EVENT_HANDLER_NAME.test(frame.functionName)) return true;
|
||||
const parameters = frame.functionInspection?.parameterNames || [];
|
||||
return parameters.some((name) => /^(?:event|evt)$/i.test(name))
|
||||
&& /(?:Element|Document|Window)/.test(frame.thisPreview);
|
||||
}
|
||||
|
||||
function hintedFrameOrder(
|
||||
left: BrowserDeepCaptureFrame,
|
||||
right: BrowserDeepCaptureFrame,
|
||||
hints: BrowserBusinessFrameHint[],
|
||||
): number {
|
||||
const leftHint = matchingHint(left, hints);
|
||||
const rightHint = matchingHint(right, hints);
|
||||
return (leftHint?.averageDepth ?? Number.POSITIVE_INFINITY) - (rightHint?.averageDepth ?? Number.POSITIVE_INFINITY)
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function rankFrame(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFrameHint[]): { score: number; reasons: string[] } {
|
||||
let score = 0;
|
||||
const reasons: string[] = [];
|
||||
if (frame.sourceKind === 'extension-hook') return { score: 0, reasons: ['插件观测帧已排除'] };
|
||||
if (frame.sourceKind === 'page') {
|
||||
score += 42;
|
||||
reasons.push('页面自身代码');
|
||||
} else {
|
||||
score += 8;
|
||||
reasons.push('第三方依赖代码');
|
||||
}
|
||||
|
||||
const proximity = Math.max(0, 18 - frame.index * 2);
|
||||
score += proximity;
|
||||
if (proximity >= 10) reasons.push('靠近目标边界');
|
||||
|
||||
const inspection = frame.functionInspection;
|
||||
if (inspection?.resolved) {
|
||||
score += 14;
|
||||
reasons.push('函数引用可解析');
|
||||
}
|
||||
const risks = inspection?.riskFlags || [];
|
||||
if (!risks.length && inspection?.resolved) {
|
||||
score += 12;
|
||||
reasons.push('未发现明显副作用');
|
||||
} else {
|
||||
if (risks.includes('network')) score -= 24;
|
||||
if (risks.includes('navigation')) score -= 22;
|
||||
if (risks.includes('dom')) score -= 12;
|
||||
if (risks.includes('storage')) score -= 5;
|
||||
if (risks.length) reasons.push('包含可见副作用');
|
||||
}
|
||||
|
||||
if (IDENTIFIER.test(frame.functionName) && frame.functionName !== '(anonymous)') {
|
||||
score += frame.functionName.length <= 2 ? 2 : 9;
|
||||
reasons.push(frame.functionName.length <= 2 ? '名称已混淆' : '具名业务函数');
|
||||
}
|
||||
|
||||
const localVariables = frame.scopes
|
||||
.filter((scope) => scope.type === 'local' || scope.type === 'closure')
|
||||
.reduce((count, scope) => count + scope.variables.length, 0);
|
||||
if (localVariables) {
|
||||
score += Math.min(10, Math.ceil(localVariables / 3));
|
||||
reasons.push('具有可分析参数或闭包现场');
|
||||
}
|
||||
|
||||
const hint = matchingHint(frame, hints);
|
||||
if (hint) {
|
||||
score += Math.min(28, 16 + hint.support * 4);
|
||||
reasons.push(hint.support > 1 ? `${hint.support} 个密码调用的共同业务祖先` : '录制调用栈中的业务祖先');
|
||||
}
|
||||
|
||||
return { score: Math.max(0, Math.min(100, score)), reasons: reasons.slice(0, 6) };
|
||||
}
|
||||
|
||||
export function rankBusinessFrames(
|
||||
frames: BrowserDeepCaptureFrame[],
|
||||
hints: BrowserBusinessFrameHint[] = [],
|
||||
): RankedBusinessFrames {
|
||||
const ranked = frames.map((frame) => {
|
||||
const rank = rankFrame(frame, hints);
|
||||
return { ...frame, businessScore: rank.score, businessReasons: rank.reasons };
|
||||
});
|
||||
const ordered = ranked
|
||||
.filter((frame) => frame.sourceKind === 'page' && (frame.businessScore || 0) >= 40)
|
||||
.sort((left, right) => (right.businessScore || 0) - (left.businessScore || 0)
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id));
|
||||
const recommended = ordered[0];
|
||||
const resolvedHinted = ranked
|
||||
.filter((frame) => frame.sourceKind === 'page' && frame.functionInspection?.resolved && matchingHint(frame, hints))
|
||||
.sort((left, right) => hintedFrameOrder(left, right, hints));
|
||||
const closestHinted = resolvedHinted[0];
|
||||
const closestRisks = closestHinted?.functionInspection?.riskFlags || [];
|
||||
const transactionRequired = Boolean(closestHinted
|
||||
&& (isEventHandler(closestHinted) || closestRisks.some((risk) => TRANSACTION_RISKS.has(risk))));
|
||||
const transactionBlocked = Boolean(transactionRequired && closestRisks.includes('storage'));
|
||||
const eligible = ordered.filter((frame) => frame.functionInspection?.resolved
|
||||
&& !frame.functionInspection.riskFlags.length && !isEventHandler(frame));
|
||||
const automaticEligible = hints.length
|
||||
? eligible.filter((frame) => Boolean(matchingHint(frame, hints)))
|
||||
: eligible;
|
||||
const automatic = automaticEligible[0];
|
||||
const alternative = automaticEligible[1];
|
||||
let automaticCapture: RankedBusinessFrames['automaticCapture'];
|
||||
if (transactionRequired && !transactionBlocked && closestHinted) {
|
||||
automaticCapture = {
|
||||
state: 'ready',
|
||||
strategy: 'request-transaction',
|
||||
frameId: closestHinted.id,
|
||||
reason: isEventHandler(closestHinted)
|
||||
? '共同业务入口是页面事件处理器,将在隔离事务中截获并取消真实请求'
|
||||
: '共同业务函数直接读取页面或发送请求,将以隔离事务保留完整动态参数关系',
|
||||
};
|
||||
} else if (transactionBlocked && closestHinted) {
|
||||
automaticCapture = {
|
||||
state: 'blocked',
|
||||
frameId: closestHinted.id,
|
||||
reason: '共同业务函数会访问页面存储;当前事务回滚无法证明存储副作用已完全隔离',
|
||||
};
|
||||
} else if (automatic && alternative && (automatic.businessScore || 0) - (alternative.businessScore || 0) < 8) {
|
||||
automaticCapture = {
|
||||
state: 'ambiguous',
|
||||
reason: '发现多个证据接近的可复用页面函数,需要确认业务边界',
|
||||
frameId: automatic.id,
|
||||
alternativeFrameIds: eligible.slice(0, 4).map((frame) => frame.id),
|
||||
};
|
||||
} else if (automatic) {
|
||||
automaticCapture = {
|
||||
state: 'ready',
|
||||
strategy: 'selected-frame',
|
||||
frameId: automatic.id,
|
||||
reason: matchingHint(automatic, hints)
|
||||
? '已用录制调用栈与暂停现场共同确认业务函数'
|
||||
: '已定位唯一且未发现明显副作用的页面函数',
|
||||
};
|
||||
} else if (recommended?.functionInspection?.riskFlags.length) {
|
||||
automaticCapture = {
|
||||
state: 'blocked',
|
||||
frameId: recommended.id,
|
||||
reason: '最接近的业务函数会产生网络、DOM、导航或存储副作用,已阻止自动回放',
|
||||
};
|
||||
} else {
|
||||
automaticCapture = {
|
||||
state: 'unavailable',
|
||||
frameId: recommended?.id,
|
||||
reason: hints.length && eligible.length
|
||||
? '录制调用栈提示没有与暂停现场唯一对应,已停止自动选择'
|
||||
: recommended
|
||||
? '业务栈帧存在,但浏览器无法唯一解析其函数对象'
|
||||
: '当前调用栈没有可复用的页面业务函数',
|
||||
};
|
||||
}
|
||||
return { frames: ranked, recommendedFrameId: automaticCapture.frameId || recommended?.id, automaticCapture };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserDeepCaptureFrame } from '@/types/models';
|
||||
import { capturedCallableSample } from './callable-sample';
|
||||
|
||||
function frame(): BrowserDeepCaptureFrame {
|
||||
return {
|
||||
id: 'frame-1', index: 1, functionName: 'buildLoginEnvelope', scriptId: '7', url: 'https://example.test/app.js',
|
||||
lineNumber: 12, columnNumber: 3, thisPreview: 'Window', sourceKind: 'page', libraryFrame: false,
|
||||
functionInspection: { resolved: true, parameterCount: 3, parameterNames: ['password', 'account', 'attempt'], riskFlags: [] },
|
||||
scopes: [
|
||||
{ type: 'closure', variables: [{ name: 'account', type: 'string', preview: 'closure-account' }] },
|
||||
{ type: 'local', variables: [
|
||||
{ name: 'password', type: 'string', preview: 'secret-value' },
|
||||
{ name: 'account', type: 'string', preview: 'analyst' },
|
||||
{ name: 'attempt', type: 'number', preview: '2' },
|
||||
{ name: 'unrelated', type: 'string', preview: 'ignored' },
|
||||
] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('captured callable replay sample', () => {
|
||||
it('uses exact parameters from the nearest authorized scope only', () => {
|
||||
expect(capturedCallableSample(frame())).toEqual({
|
||||
body: '{\n "password": "secret-value",\n "account": "analyst",\n "attempt": 2\n}',
|
||||
label: 'buildLoginEnvelope · 暂停现场',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent values for unresolved parameters', () => {
|
||||
const input = frame();
|
||||
input.functionInspection!.parameterNames = ['missing'];
|
||||
expect(capturedCallableSample(input)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not use a truncated scope preview as a replay value', () => {
|
||||
const input = frame();
|
||||
input.functionInspection!.parameterNames = ['password'];
|
||||
input.scopes[1].variables[0].detailTruncated = true;
|
||||
expect(capturedCallableSample(input)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BrowserDeepCaptureFrame, BrowserDeepCaptureVariable } from '@/types/models';
|
||||
|
||||
export interface CapturedCallableSample {
|
||||
body: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SCOPE_PRIORITY: Record<string, number> = {
|
||||
local: 0,
|
||||
block: 1,
|
||||
catch: 2,
|
||||
closure: 3,
|
||||
module: 4,
|
||||
script: 5,
|
||||
with: 6,
|
||||
'wasm-expression-stack': 7,
|
||||
};
|
||||
|
||||
function variableValue(variable: BrowserDeepCaptureVariable): { resolved: boolean; value?: unknown } {
|
||||
if (variable.detailTruncated) return { resolved: false };
|
||||
const text = variable.detail ?? variable.preview;
|
||||
if (variable.type === 'string') return { resolved: true, value: text };
|
||||
if (variable.type === 'number') {
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? { resolved: true, value } : { resolved: false };
|
||||
}
|
||||
if (variable.type === 'boolean') {
|
||||
if (text === 'true') return { resolved: true, value: true };
|
||||
if (text === 'false') return { resolved: true, value: false };
|
||||
return { resolved: false };
|
||||
}
|
||||
if (variable.type === 'bigint' && /^-?\d+n?$/.test(text)) {
|
||||
return { resolved: true, value: text.replace(/n$/, '') };
|
||||
}
|
||||
if (variable.subtype === 'null') return { resolved: true, value: null };
|
||||
if (variable.type === 'object' && (/^\s*\{/.test(text) || /^\s*\[/.test(text))) {
|
||||
try { return { resolved: true, value: JSON.parse(text) }; } catch { return { resolved: false }; }
|
||||
}
|
||||
return { resolved: false };
|
||||
}
|
||||
|
||||
export function capturedCallableSample(frame?: BrowserDeepCaptureFrame): CapturedCallableSample | undefined {
|
||||
const parameterNames = frame?.functionInspection?.parameterNames || [];
|
||||
if (!frame || !parameterNames.length) return undefined;
|
||||
const scopes = [...frame.scopes].sort((left, right) => (
|
||||
(SCOPE_PRIORITY[left.type] ?? 99) - (SCOPE_PRIORITY[right.type] ?? 99)
|
||||
));
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const parameterName of parameterNames) {
|
||||
const variable = scopes.flatMap((scope) => scope.variables).find((item) => item.name === parameterName);
|
||||
if (!variable) continue;
|
||||
const parsed = variableValue(variable);
|
||||
if (parsed.resolved) body[parameterName] = parsed.value;
|
||||
}
|
||||
if (!Object.keys(body).length) return undefined;
|
||||
return {
|
||||
body: JSON.stringify(body, null, 2),
|
||||
label: `${frame.functionName && frame.functionName !== '(anonymous)' ? frame.functionName : '页面函数'} · 暂停现场`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
.deep-capture {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.deep-capture__command {
|
||||
min-height: 54px;
|
||||
padding: 7px 8px 7px 13px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, auto) minmax(420px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.deep-capture__identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.deep-capture__identity > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deep-capture__identity strong,
|
||||
.deep-capture__identity small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.deep-capture__identity strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.deep-capture__identity small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.deep-status-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.deep-status-dot i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.deep-status-dot.state-armed { background: var(--warning-soft); }
|
||||
.deep-status-dot.state-armed i { background: var(--warning); animation: pulse 1.1s infinite; }
|
||||
.deep-status-dot.state-paused { background: var(--danger-soft); }
|
||||
.deep-status-dot.state-paused i { background: var(--danger); animation: pulse .8s infinite; }
|
||||
.deep-status-dot.state-captured, .deep-status-dot.state-attached { background: var(--success-soft); }
|
||||
.deep-status-dot.state-captured i, .deep-status-dot.state-attached i { background: var(--success); }
|
||||
.deep-status-dot.state-error { background: var(--danger-soft); }
|
||||
.deep-status-dot.state-error i { background: var(--danger); }
|
||||
|
||||
.deep-stage-strip {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.deep-stage-strip li {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.deep-stage-strip li > span {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.deep-stage-strip li em {
|
||||
overflow: hidden;
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.deep-stage-strip li > svg { flex: 0 0 auto; color: var(--border-strong); }
|
||||
.deep-stage-strip li.is-done { color: var(--success); }
|
||||
.deep-stage-strip li.is-done > span { border-color: var(--success); background: var(--success-soft); }
|
||||
.deep-stage-strip li.is-current { color: var(--foreground); font-weight: 650; }
|
||||
.deep-stage-strip li.is-current > span { border-color: var(--primary); box-shadow: 0 0 0 2px var(--focus); color: var(--primary); }
|
||||
|
||||
.deep-capture__command-actions { display: flex; align-items: center; gap: 3px; }
|
||||
|
||||
.deep-message {
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.deep-message.is-error { border-color: var(--danger); background: var(--danger-soft); color: var(--danger); }
|
||||
|
||||
.deep-arm-panel {
|
||||
min-height: 72px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(320px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.deep-arm-panel__mode {
|
||||
height: 36px;
|
||||
padding: 3px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.deep-arm-panel__mode button {
|
||||
min-width: 112px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted-strong);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-arm-panel__mode button.is-selected {
|
||||
background: var(--surface);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.deep-arm-panel__mode button.is-selected svg { color: var(--primary); }
|
||||
|
||||
.deep-arm-panel__fields {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, .8fr) minmax(220px, 1.2fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deep-arm-panel__fields label,
|
||||
.deep-adapter-runner label,
|
||||
.deep-adapter-editor label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.deep-arm-panel__fields label.is-wide { grid-column: 1 / -1; }
|
||||
.deep-arm-panel__fields label > span,
|
||||
.deep-adapter-runner label > span,
|
||||
.deep-adapter-editor label > span {
|
||||
color: var(--muted-strong);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.deep-arm-panel__fields input { width: 100%; }
|
||||
|
||||
.deep-waiting {
|
||||
min-height: 82px;
|
||||
padding: 0 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 48px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 30%, var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.deep-waiting > span {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.deep-waiting strong, .deep-waiting small { display: block; }
|
||||
.deep-waiting strong { font-size: var(--text-md); }
|
||||
.deep-waiting small { margin-top: 3px; overflow: hidden; color: var(--muted-strong); font-size: var(--text-xs); white-space: nowrap; text-overflow: ellipsis; }
|
||||
.deep-waiting > i { width: 44px; height: 3px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--warning) 20%, var(--surface)); }
|
||||
.deep-waiting > i::after { content: ''; display: block; width: 45%; height: 100%; border-radius: inherit; background: var(--warning); animation: deep-wait 1.15s ease-in-out infinite alternate; }
|
||||
@keyframes deep-wait { to { transform: translateX(120%); } }
|
||||
|
||||
.deep-adapter-lab {
|
||||
min-height: 300px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, .8fr) minmax(380px, 1.2fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.deep-adapter-list { min-width: 0; border-right: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.deep-adapter-list > header,
|
||||
.deep-adapter-runner > header,
|
||||
.deep-stack > header,
|
||||
.deep-scopes > header,
|
||||
.deep-adapter-editor > header {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.deep-adapter-list > header > div,
|
||||
.deep-adapter-runner > header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.deep-adapter-list header svg,
|
||||
.deep-adapter-runner header svg,
|
||||
.deep-stack header svg,
|
||||
.deep-scopes header svg,
|
||||
.deep-adapter-editor header svg { color: var(--primary); }
|
||||
.deep-adapter-list header strong,
|
||||
.deep-adapter-runner header strong,
|
||||
.deep-stack header strong,
|
||||
.deep-scopes header strong,
|
||||
.deep-adapter-editor header strong { font-size: var(--text-sm); }
|
||||
.deep-adapter-list header > span,
|
||||
.deep-adapter-runner header > span,
|
||||
.deep-stack header > span,
|
||||
.deep-scopes header > span { color: var(--muted); font-size: var(--text-xs); }
|
||||
|
||||
.deep-adapter-list > button {
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 9px 11px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-adapter-list > button:hover { background: var(--surface); }
|
||||
.deep-adapter-list > button.is-selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.deep-adapter-list > button span { min-width: 0; }
|
||||
.deep-adapter-list > button strong,
|
||||
.deep-adapter-list > button small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.deep-adapter-list > button strong { font-size: var(--text-sm); }
|
||||
.deep-adapter-list > button small { margin-top: 4px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.deep-adapter-list > button > svg { color: var(--border-strong); }
|
||||
|
||||
.deep-column-empty {
|
||||
min-height: 180px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deep-column-empty svg { color: var(--border-strong); }
|
||||
|
||||
.deep-adapter-runner {
|
||||
min-width: 0;
|
||||
padding: 0 14px 14px;
|
||||
display: grid;
|
||||
grid-template-rows: 44px auto auto 1fr;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.deep-adapter-runner > header { margin: 0 -14px; }
|
||||
.deep-adapter-runner textarea,
|
||||
.deep-adapter-editor textarea { resize: vertical; font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.5; }
|
||||
.deep-adapter-runner__actions,
|
||||
.deep-adapter-editor__actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
|
||||
.deep-execution-result { min-width: 0; display: grid; gap: 6px; }
|
||||
.deep-execution-result > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.deep-execution-result strong { font-size: var(--text-sm); }
|
||||
.deep-execution-result span { color: var(--muted); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.deep-execution-result pre {
|
||||
max-height: 220px;
|
||||
padding: 11px 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.deep-paused-workbench {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 3px 0 var(--danger);
|
||||
}
|
||||
|
||||
.deep-paused-banner {
|
||||
min-height: 50px;
|
||||
padding: 7px 9px 7px 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.deep-paused-banner > div { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--danger); }
|
||||
.deep-paused-banner strong { font-size: var(--text-sm); }
|
||||
.deep-paused-banner span { color: var(--muted-strong); font-size: var(--text-xs); }
|
||||
|
||||
.deep-recorded-recommendation {
|
||||
min-height: 108px;
|
||||
padding: 16px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--success) 25%, var(--border));
|
||||
background: linear-gradient(90deg, var(--success-soft), color-mix(in srgb, var(--success-soft) 20%, var(--surface)) 62%, var(--surface));
|
||||
animation: deep-recommendation-in .22s ease-out both;
|
||||
}
|
||||
|
||||
.deep-recorded-recommendation > span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--success) 32%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.deep-recorded-recommendation > div { min-width: 0; }
|
||||
.deep-recorded-recommendation small,
|
||||
.deep-recorded-recommendation strong { display: block; }
|
||||
.deep-recorded-recommendation small { color: var(--success); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.deep-recorded-recommendation strong { margin-top: 2px; font-size: var(--text-md); }
|
||||
.deep-recorded-recommendation p { margin: 4px 0 0; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.55; }
|
||||
.deep-recorded-recommendation > div > div { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.deep-recorded-recommendation i {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--success) 23%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
color: var(--muted-strong);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.deep-auto-resolution {
|
||||
min-height: 82px;
|
||||
padding: 13px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
animation: deep-recommendation-in .2s ease-out both;
|
||||
}
|
||||
|
||||
.deep-auto-resolution > span {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.deep-auto-resolution.is-blocked > span,
|
||||
.deep-auto-resolution.is-unavailable > span {
|
||||
border-color: color-mix(in srgb, var(--danger) 28%, var(--border));
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.deep-auto-resolution > div { min-width: 0; }
|
||||
.deep-auto-resolution small,
|
||||
.deep-auto-resolution strong { display: block; }
|
||||
.deep-auto-resolution small { color: var(--primary-text); font-size: 10px; font-weight: 750; }
|
||||
.deep-auto-resolution strong { margin-top: 2px; font-size: var(--text-sm); }
|
||||
.deep-auto-resolution p { margin: 4px 0 0; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; }
|
||||
.deep-auto-resolution > i { display: flex; align-items: center; gap: 7px; color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; white-space: nowrap; }
|
||||
.deep-auto-resolution > i > span { width: 7px; height: 7px; border-radius: 50%; background: var(--primary); animation: deep-auto-pulse 1.1s ease-in-out infinite; }
|
||||
|
||||
.deep-manual-capture { min-width: 0; }
|
||||
.deep-manual-capture > summary {
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-subtle);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.deep-manual-capture > summary::-webkit-details-marker { display: none; }
|
||||
.deep-manual-capture > summary > span { display: flex; align-items: center; gap: 7px; }
|
||||
.deep-manual-capture > summary svg { color: var(--primary); }
|
||||
.deep-manual-capture > summary strong { font-size: var(--text-sm); }
|
||||
.deep-manual-capture > summary em { color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||
.deep-manual-capture:not([open]) > summary { border-bottom: 0; }
|
||||
|
||||
.deep-paused-grid {
|
||||
min-height: 570px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(235px, .72fr) minmax(360px, 1.25fr) minmax(310px, .9fr);
|
||||
}
|
||||
|
||||
.deep-stack, .deep-scopes, .deep-adapter-editor { min-width: 0; min-height: 0; }
|
||||
.deep-stack { border-right: 1px solid var(--border); background: var(--surface-subtle); }
|
||||
.deep-stack > header,
|
||||
.deep-scopes > header,
|
||||
.deep-adapter-editor > header { justify-content: flex-start; }
|
||||
.deep-stack > header span,
|
||||
.deep-scopes > header span { margin-left: auto; }
|
||||
.deep-stack > div { max-height: 650px; overflow: auto; }
|
||||
|
||||
.deep-stack button {
|
||||
width: 100%;
|
||||
min-height: 57px;
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-stack button:hover { background: var(--surface); }
|
||||
.deep-stack button.is-selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.deep-stack button.is-library { color: var(--muted-strong); }
|
||||
.deep-frame-index { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font-family: var(--font-mono); font-size: 10px; }
|
||||
.deep-stack button span:last-child { min-width: 0; }
|
||||
.deep-stack button strong, .deep-stack button small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.deep-stack button strong { font-size: var(--text-sm); }
|
||||
.deep-stack button small { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.deep-frame-badges { display: flex; align-items: flex-end; flex-direction: column; gap: 4px; }
|
||||
.deep-frame-badges > em { padding: 2px 5px; border-radius: 999px; background: var(--surface); color: var(--muted-strong); font-size: 9px; font-style: normal; line-height: 1.25; white-space: nowrap; }
|
||||
.deep-frame-badges > em.source-extension-hook { background: var(--warning-soft); color: var(--warning); }
|
||||
.deep-frame-badges > em.source-page { background: var(--primary-soft); color: var(--primary-text); }
|
||||
.deep-frame-badges > em.source-library { background: var(--surface); color: var(--muted); }
|
||||
.deep-frame-badges > em.has-risk { background: var(--danger-soft); color: var(--danger); }
|
||||
.deep-frame-badges > em.is-clean { background: var(--success-soft); color: var(--success); }
|
||||
|
||||
.deep-scopes { border-right: 1px solid var(--border); }
|
||||
.deep-scopes > div { max-height: 650px; overflow: auto; }
|
||||
.deep-scopes section + section { border-top: 1px solid var(--border); }
|
||||
.deep-scopes h4 {
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
.deep-scopes h4 span { color: var(--primary-text); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.deep-scopes h4 small { overflow: hidden; color: var(--muted); font-size: 10px; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
.deep-scope-variable > button {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 5px 9px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(85px, .7fr) minmax(120px, 1.3fr) 65px 14px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-scope-variable > button:hover,
|
||||
.deep-scope-variable.is-expanded > button { background: var(--primary-soft); }
|
||||
.deep-scope-variable > button > svg { color: var(--muted); transition: transform 140ms ease; }
|
||||
.deep-scope-variable.is-expanded > button > svg { transform: rotate(180deg); }
|
||||
.deep-scopes code,
|
||||
.deep-scope-variable > button > span,
|
||||
.deep-scope-variable > button > em { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.deep-scopes code { color: var(--foreground); font-size: var(--text-xs); }
|
||||
.deep-scope-variable > button > span { color: var(--muted-strong); font-family: var(--font-mono); font-size: 10px; }
|
||||
.deep-scope-variable > button > em { color: var(--muted); font-size: 10px; font-style: normal; text-align: right; }
|
||||
.deep-scope-variable__detail { padding: 8px 9px 10px; border-top: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border)); background: color-mix(in srgb, var(--surface-subtle) 70%, var(--surface)); animation: deep-variable-reveal 140ms ease-out; }
|
||||
.deep-scope-variable__detail > header { min-height: 28px; padding: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 0; }
|
||||
.deep-scope-variable__detail > header > span { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||
.deep-scope-variable__detail > header > div { display: flex; align-items: center; gap: 4px; }
|
||||
.deep-scope-variable__detail pre { max-height: 260px; margin: 5px 0 0; padding: 9px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--foreground); font-family: var(--font-mono); font-size: 10px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.deep-adapter-editor {
|
||||
padding: 0 13px 13px;
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
align-content: start;
|
||||
}
|
||||
.deep-adapter-editor > header { margin: 0 -13px; }
|
||||
.deep-frame-summary { min-width: 0; padding: 9px 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.deep-frame-summary strong, .deep-frame-summary small, .deep-frame-summary span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.deep-frame-summary strong { font-size: var(--text-md); }
|
||||
.deep-frame-summary small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); }
|
||||
.deep-frame-summary span { margin-top: 6px; color: var(--muted-strong); font-family: var(--font-mono); font-size: 10px; }
|
||||
|
||||
.deep-function-assessment {
|
||||
min-height: 55px;
|
||||
padding: 9px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.deep-function-assessment.is-clean { border-color: color-mix(in srgb, var(--success) 28%, var(--border)); background: var(--success-soft); color: var(--success); }
|
||||
.deep-function-assessment.is-hook { border-color: color-mix(in srgb, var(--warning) 30%, var(--border)); background: var(--warning-soft); color: var(--warning); }
|
||||
.deep-function-assessment svg { margin-top: 1px; }
|
||||
.deep-function-assessment strong,
|
||||
.deep-function-assessment small { display: block; }
|
||||
.deep-function-assessment strong { font-size: var(--text-xs); }
|
||||
.deep-function-assessment small { margin-top: 3px; color: var(--muted-strong); font-size: 10px; line-height: 1.45; }
|
||||
|
||||
.deep-adapter-editor__primary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.deep-adapter-editor__primary > button { width: 100%; }
|
||||
.deep-adapter-editor__primary > small { color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
|
||||
.deep-expression-editor {
|
||||
padding: 0 10px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
.deep-expression-editor > summary {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted-strong);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
.deep-expression-editor > p { margin: 0 0 10px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.deep-expression-editor > p code { color: var(--primary-text); font-family: var(--font-mono); }
|
||||
.deep-expression-editor label { min-width: 0; display: grid; gap: 4px; margin-top: 8px; }
|
||||
.deep-expression-editor label > span { color: var(--muted-strong); font-size: var(--text-xs); font-weight: 650; }
|
||||
.deep-expression-editor input,
|
||||
.deep-expression-editor textarea { width: 100%; }
|
||||
.deep-expression-editor .deep-adapter-editor__actions { margin-top: 9px; }
|
||||
|
||||
@keyframes deep-recommendation-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes deep-variable-reveal {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes deep-auto-pulse {
|
||||
0%, 100% { opacity: .35; transform: scale(.82); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
[data-theme='dark'] .deep-execution-result pre { border-color: #262c33; background: #12161b; color: #d6dde4; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.deep-capture__command { grid-template-columns: minmax(180px, 1fr) auto; }
|
||||
.deep-stage-strip { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; padding: 4px 2px; overflow-x: auto; }
|
||||
.deep-capture__command-actions { grid-column: 2; grid-row: 1; }
|
||||
.deep-paused-grid { grid-template-columns: minmax(220px, .75fr) minmax(360px, 1.25fr); }
|
||||
.deep-adapter-editor { grid-column: 1 / -1; border-top: 1px solid var(--border); }
|
||||
.deep-scopes { border-right: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.deep-arm-panel { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
|
||||
.deep-arm-panel__mode button { min-width: 0; }
|
||||
.deep-adapter-lab, .deep-paused-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.deep-adapter-list, .deep-stack, .deep-scopes { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.deep-adapter-editor { grid-column: auto; border-top: 0; }
|
||||
.deep-stack > div, .deep-scopes > div { max-height: 360px; }
|
||||
.deep-recorded-recommendation { grid-template-columns: 34px minmax(0, 1fr); }
|
||||
.deep-recorded-recommendation > button { grid-column: 1 / -1; width: 100%; }
|
||||
.deep-auto-resolution { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.deep-auto-resolution > i { grid-column: 1 / -1; padding-left: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.deep-capture__command { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.deep-stage-strip em { display: none; }
|
||||
.deep-stage-strip li { gap: 3px; }
|
||||
.deep-arm-panel__fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.deep-arm-panel__fields label.is-wide { grid-column: auto; }
|
||||
.deep-scope-variable > button { grid-template-columns: minmax(80px, .8fr) minmax(100px, 1.2fr) 14px; }
|
||||
.deep-scope-variable > button > em { display: none; }
|
||||
.deep-recorded-recommendation { padding: 13px; }
|
||||
.deep-manual-capture > summary > em { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.deep-recorded-recommendation, .deep-auto-resolution, .deep-auto-resolution > i > span, .deep-scope-variable__detail { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseFunctionParameterNames } from './function-parameters';
|
||||
|
||||
describe('deep-capture function parameter parser', () => {
|
||||
it.each([
|
||||
['async function buildLoginEnvelope(password, account = "analyst") {}', ['password', 'account']],
|
||||
['encrypt(value, options = { mode: "CBC", fields: ["a", "b"] }) {}', ['value', 'options']],
|
||||
['(payload, ...rest) => payload', ['payload', 'rest']],
|
||||
['async value => value', ['value']],
|
||||
['function transform({ value }, [key]) {}', ['arg0', 'arg1']],
|
||||
])('reads runtime source parameters from %s', (source, expected) => {
|
||||
expect(parseFunctionParameterNames(source)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('keeps the parser bounded', () => {
|
||||
expect(parseFunctionParameterNames('(a, b, c) => a', 2)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export function parseFunctionParameterNames(source: string, limit = 16): string[] {
|
||||
const arrow = source.indexOf('=>');
|
||||
const start = source.indexOf('(');
|
||||
let content = '';
|
||||
|
||||
if (start >= 0 && (arrow < 0 || start < arrow)) {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (character === '\\') escaped = true;
|
||||
else if (character === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === '`') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === '(') depth += 1;
|
||||
else if (character === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
content = source.slice(start + 1, index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (arrow > 0) {
|
||||
content = source.slice(0, arrow).replace(/^\s*async\s+/, '').trim();
|
||||
}
|
||||
|
||||
if (!content.trim()) return [];
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const character of content) {
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += character;
|
||||
if (character === '\\') escaped = true;
|
||||
else if (character === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === '`') {
|
||||
quote = character;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
if ('([{'.includes(character)) depth += 1;
|
||||
else if (')]}'.includes(character)) depth = Math.max(0, depth - 1);
|
||||
if (character === ',' && depth === 0) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += character;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
|
||||
return parts.slice(0, Math.max(0, limit)).map((part, index) => {
|
||||
const candidate = part.trim().replace(/^\.\.\./, '').split('=', 1)[0].trim();
|
||||
return /^[A-Za-z_$][\w$]*$/.test(candidate) ? candidate : `arg${index}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const sessionStore: Record<string, unknown> = {};
|
||||
const detachListeners: Array<(source: { tabId?: number }, reason: string) => void> = [];
|
||||
|
||||
const debuggerApi = {
|
||||
attach: vi.fn(async () => undefined),
|
||||
detach: vi.fn(async (target: { tabId?: number }) => {
|
||||
for (const listener of detachListeners) listener(target, 'canceled_by_user');
|
||||
}),
|
||||
getTargets: vi.fn(async () => []),
|
||||
sendCommand: vi.fn(async () => ({})),
|
||||
onEvent: { addListener: vi.fn() },
|
||||
onDetach: { addListener: vi.fn((listener: (source: { tabId?: number }, reason: string) => void) => detachListeners.push(listener)) },
|
||||
};
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: {
|
||||
storage: {
|
||||
session: {
|
||||
get: vi.fn(async (key: string) => ({ [key]: sessionStore[key] })),
|
||||
set: vi.fn(async (values: Record<string, unknown>) => Object.assign(sessionStore, values)),
|
||||
},
|
||||
},
|
||||
runtime: { sendMessage: vi.fn(async () => undefined) },
|
||||
alarms: {
|
||||
create: vi.fn(async () => undefined),
|
||||
clear: vi.fn(async () => true),
|
||||
onAlarm: { addListener: vi.fn() },
|
||||
},
|
||||
tabs: { onRemoved: { addListener: vi.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/browser-recording/service', () => ({
|
||||
armBrowserRecordingDeepBreak: vi.fn(async () => undefined),
|
||||
disarmBrowserRecordingDeepBreak: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.stubGlobal('chrome', { debugger: debuggerApi });
|
||||
|
||||
import {
|
||||
deepCaptureStatus,
|
||||
initializeDeepCaptureService,
|
||||
resumeDeepCapture,
|
||||
startDeepCapture,
|
||||
} from './service';
|
||||
|
||||
describe('deep capture debugger lifecycle', () => {
|
||||
beforeAll(() => initializeDeepCaptureService());
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(sessionStore)) delete sessionStore[key];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('detaches after a one-shot capture without treating its own detach event as an error', async () => {
|
||||
const target = { tabId: 17, frameId: 0 };
|
||||
|
||||
await startDeepCapture(target, { kind: 'request', urlPattern: '/login' });
|
||||
const resumed = await resumeDeepCapture(target, 'callable-created');
|
||||
await vi.waitFor(async () => expect((await deepCaptureStatus(target)).state).toBe('captured'));
|
||||
|
||||
expect(debuggerApi.detach).toHaveBeenCalledWith({ tabId: 17 });
|
||||
expect(resumed).toMatchObject({ state: 'captured', error: undefined });
|
||||
expect(await deepCaptureStatus(target)).toMatchObject({ state: 'captured', error: undefined });
|
||||
});
|
||||
|
||||
it('treats the browser debug banner cancel action as a normal detach', async () => {
|
||||
const target = { tabId: 18, frameId: 0 };
|
||||
await startDeepCapture(target, { kind: 'request', urlPattern: '/account' });
|
||||
|
||||
for (const listener of detachListeners) listener({ tabId: target.tabId }, 'canceled_by_user');
|
||||
await vi.waitFor(async () => expect((await deepCaptureStatus(target)).state).toBe('detached'));
|
||||
|
||||
expect(await deepCaptureStatus(target)).toMatchObject({ state: 'detached', error: undefined });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,12 @@ export async function createDiagnosticsBundle(bridge: BridgeStatus): Promise<Dia
|
||||
state: {
|
||||
proxyProfiles: state.proxyProfiles.length,
|
||||
proxyRules: state.proxyRules.length,
|
||||
userAgentRules: state.userAgentRules.length,
|
||||
proxyRuleSources: state.proxyRuleSources.length,
|
||||
proxySourceRules: state.proxyRuleSources.reduce((total, source) => total + source.supportedRuleCount, 0),
|
||||
proxyCompiledBytes: state.proxyRuntime.compiledBytes,
|
||||
proxyConfigurationDirty: state.proxyRuntime.dirty,
|
||||
customUserAgentProfiles: state.customUserAgentProfiles.length,
|
||||
userAgentAssignments: state.userAgentAssignments.length,
|
||||
floatingPanelEnabled: state.floatingPanel.enabled,
|
||||
activeGrant: Boolean(state.activeGrant),
|
||||
activeGrantTargets: state.activeGrant?.targets.length || 0,
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, yakIcon
|
||||
{expanded && <>
|
||||
<div className="floating-panel__title">
|
||||
<strong>Yakit Browser Agent</strong>
|
||||
<span>{activeProfile?.name || (state.activeProxyId === 'rules' ? '按规则分流' : '浏览器工具')}</span>
|
||||
<span>{activeProfile?.name || (state.activeProxyId === 'auto' ? '自动切换' : '浏览器工具')}</span>
|
||||
</div>
|
||||
<GripVertical className="floating-panel__grip" size={15} aria-hidden="true" />
|
||||
{side === 'right' ? <ChevronRight size={15} /> : <ChevronLeft size={15} />}
|
||||
@@ -182,7 +182,7 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, yakIcon
|
||||
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
|
||||
</button>
|
||||
))}
|
||||
{state.proxyRules.length > 0 && <button className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}><i className="floating-radio" /><span><strong>按规则分流</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条启用规则</small></span></button>}
|
||||
<button className={state.activeProxyId === 'auto' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.auto.apply')))}><i className="floating-radio" /><span><strong>自动切换</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条手动 · {state.proxyRuleSources.filter((source) => source.enabled).length} 个订阅</small></span></button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
+128
-25
@@ -5,12 +5,23 @@ import {
|
||||
stopNetworkCapturesForGrant,
|
||||
} from '@/features/network-capture/service';
|
||||
import {
|
||||
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
|
||||
stopPageObservation, stopPageObservationsForGrant,
|
||||
} from '@/features/page-observation/service';
|
||||
browserRecordingStatus, clearBrowserRecording, createRecordedPageCallable, getBrowserRecording, startBrowserRecording,
|
||||
stopBrowserRecording, stopBrowserRecordingsForGrant,
|
||||
} from '@/features/browser-recording/service';
|
||||
import {
|
||||
createCapturedPageCallable, deepCaptureStatus, detachDeepCapture,
|
||||
keepDeepCaptureAlive, resumeDeepCapture,
|
||||
startDeepCapture, stopDeepCapturesForGrant,
|
||||
} from '@/features/deep-capture/service';
|
||||
import { deletePageCallable, executePageCallable, listPageCallables } from '@/features/page-callable/service';
|
||||
import {
|
||||
deleteBrowserTransformProfile, executeBrowserTransform, getBrowserTransformProfile,
|
||||
listBrowserTransformProfiles, saveBrowserTransformProfile,
|
||||
} from '@/features/browser-transform/service';
|
||||
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
|
||||
import type {
|
||||
BridgeGrant, BrowserRequestAnalysisBundle, BrowserTarget, CapabilityScope, HandoffReason,
|
||||
BridgeGrant, BrowserDeepCaptureMatcher, BrowserRequestAnalysisBundle, BrowserTarget,
|
||||
BrowserTransformExecuteInput, BrowserTransformProfileInput, CapabilityScope, HandoffReason,
|
||||
PageContextOptions, YakPocGenerateResult,
|
||||
} from '@/types/models';
|
||||
import { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
@@ -46,11 +57,24 @@ const CAPABILITY_SCOPES: Record<string, CapabilityScope> = {
|
||||
'browser.network.export': 'browser.network.sensitive.read',
|
||||
'browser.network.poc': 'browser.network.sensitive.read',
|
||||
'browser.network.analysis': 'browser.network.sensitive.read',
|
||||
'browser.observe.start': 'browser.observation.control',
|
||||
'browser.observe.status': 'browser.observation.read',
|
||||
'browser.observe.list': 'browser.observation.read',
|
||||
'browser.observe.clear': 'browser.observation.control',
|
||||
'browser.observe.stop': 'browser.observation.control',
|
||||
'browser.recording.start': 'browser.recording.control',
|
||||
'browser.recording.status': 'browser.recording.read',
|
||||
'browser.recording.get': 'browser.recording.read',
|
||||
'browser.recording.clear': 'browser.recording.control',
|
||||
'browser.recording.stop': 'browser.recording.control',
|
||||
'browser.callable.create': 'browser.callable.execute',
|
||||
'browser.callable.list': 'browser.recording.read',
|
||||
'browser.callable.execute': 'browser.callable.execute',
|
||||
'browser.callable.delete': 'browser.callable.execute',
|
||||
'browser.deep_capture.start': 'browser.debugger.control',
|
||||
'browser.deep_capture.status': 'browser.debugger.read',
|
||||
'browser.deep_capture.keepalive': 'browser.debugger.control',
|
||||
'browser.deep_capture.resume': 'browser.debugger.control',
|
||||
'browser.deep_capture.detach': 'browser.debugger.control',
|
||||
'browser.transform.profile.list': 'browser.transform.read',
|
||||
'browser.transform.profile.save': 'browser.transform.manage',
|
||||
'browser.transform.profile.delete': 'browser.transform.manage',
|
||||
'browser.transform.execute': 'browser.transform.execute',
|
||||
'browser.invoke': 'browser.page.invoke',
|
||||
'browser.eval': 'browser.page.eval.expression',
|
||||
'proxy.list': 'browser.proxy.read',
|
||||
@@ -71,7 +95,8 @@ async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
}));
|
||||
await Promise.all([
|
||||
stopNetworkCapturesForGrant(grant.id),
|
||||
stopPageObservationsForGrant(grant.id),
|
||||
stopBrowserRecordingsForGrant(grant.id),
|
||||
stopDeepCapturesForGrant(grant.id),
|
||||
]);
|
||||
await setAgentRuntimeState('expired', grant);
|
||||
if (state.handoff) await browser.action.setBadgeText({ text: '', tabId: state.handoff.target.tabId });
|
||||
@@ -91,7 +116,11 @@ function originOf(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function allowedTarget(grant: BridgeGrant, input: Record<string, unknown>): Promise<BrowserTarget> {
|
||||
async function allowedTarget(
|
||||
grant: BridgeGrant,
|
||||
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
|
||||
resolveInPage = true,
|
||||
): Promise<BrowserTarget> {
|
||||
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
|
||||
const target = grant.targets.find((item) => item.tabId === requested && item.frameId === requestedFrameId);
|
||||
@@ -109,6 +138,7 @@ async function allowedTarget(grant: BridgeGrant, input: Record<string, unknown>)
|
||||
if (typeof input.documentId === 'string' && target.documentId && input.documentId !== target.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
|
||||
}
|
||||
if (!resolveInPage) return target;
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
@@ -225,32 +255,105 @@ export async function routeCapability(
|
||||
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
|
||||
return requestEngine<BrowserRequestAnalysisBundle>(
|
||||
'yakit.browser_request.prepare_analysis',
|
||||
await capturedRequestEnginePayload(target, String(input.id), grant.scopes.includes('browser.observation.read')),
|
||||
await capturedRequestEnginePayload(target, String(input.id), grant.scopes.includes('browser.recording.read')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.observe.')) {
|
||||
if (method.startsWith('browser.recording.')) {
|
||||
const target = await allowedTarget(grant, input);
|
||||
if (method === 'browser.observe.start') {
|
||||
if (input.captureValues === true) requireScope(grant, 'browser.observation.sensitive.read');
|
||||
return startPageObservation(target, {
|
||||
if (method === 'browser.recording.start') {
|
||||
if (input.captureValues === true) requireScope(grant, 'browser.recording.sensitive.read');
|
||||
return startBrowserRecording(target, {
|
||||
captureValues: input.captureValues === true,
|
||||
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
|
||||
maxValueBytes: typeof input.maxValueBytes === 'number' ? input.maxValueBytes : undefined,
|
||||
expiresAt: grant.expiresAt,
|
||||
}, { kind: 'grant', grantId: grant.id });
|
||||
}
|
||||
if (method === 'browser.observe.status') return pageObservationStatus(target);
|
||||
if (method === 'browser.observe.list') {
|
||||
return listPageObservations(
|
||||
target,
|
||||
typeof input.limit === 'number' ? input.limit : 100,
|
||||
grant.scopes.includes('browser.observation.sensitive.read'),
|
||||
);
|
||||
if (method === 'browser.recording.status') return browserRecordingStatus(target);
|
||||
if (method === 'browser.recording.get') return getBrowserRecording(
|
||||
target,
|
||||
typeof input.limit === 'number' ? input.limit : 500,
|
||||
grant.scopes.includes('browser.recording.sensitive.read'),
|
||||
);
|
||||
if (method === 'browser.recording.clear') return clearBrowserRecording(target, grant.scopes.includes('browser.recording.sensitive.read'));
|
||||
if (method === 'browser.recording.stop') return stopBrowserRecording(target, grant.scopes.includes('browser.recording.sensitive.read'));
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.callable.')) {
|
||||
const source = String(input.source || '');
|
||||
const target = await allowedTarget(grant, input, source !== 'deep-capture');
|
||||
if (method === 'browser.callable.list') return listPageCallables(target);
|
||||
if (method === 'browser.callable.create') {
|
||||
if (source === 'deep-capture') {
|
||||
requireScope(grant, 'browser.debugger.control');
|
||||
const strategy = input.strategy === 'expression' ? 'expression' : 'selected-frame';
|
||||
return createCapturedPageCallable(target, String(input.callFrameId || ''), strategy === 'expression' ? {
|
||||
strategy,
|
||||
name: String(input.name || ''),
|
||||
functionExpression: String(input.functionExpression || ''),
|
||||
} : {
|
||||
strategy,
|
||||
name: typeof input.name === 'string' ? input.name : undefined,
|
||||
}, { kind: 'grant', grantId: grant.id });
|
||||
}
|
||||
return createRecordedPageCallable(target, {
|
||||
callHandleId: String(input.callHandleId || ''),
|
||||
name: String(input.name || ''),
|
||||
});
|
||||
}
|
||||
if (method === 'browser.callable.execute') {
|
||||
return executePageCallable(target, String(input.callableId || ''), Array.isArray(input.args) ? input.args : []);
|
||||
}
|
||||
if (method === 'browser.callable.delete') return deletePageCallable(target, String(input.callableId || ''));
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.deep_capture.')) {
|
||||
const target = await allowedTarget(grant, input, method === 'browser.deep_capture.start');
|
||||
const owner = { kind: 'grant' as const, grantId: grant.id };
|
||||
if (method === 'browser.deep_capture.start') {
|
||||
return startDeepCapture(target, input.matcher as BrowserDeepCaptureMatcher, owner);
|
||||
}
|
||||
if (method === 'browser.deep_capture.status') return deepCaptureStatus(target, owner);
|
||||
if (method === 'browser.deep_capture.keepalive') return keepDeepCaptureAlive(target, owner);
|
||||
if (method === 'browser.deep_capture.resume') return resumeDeepCapture(target, 'engine-request', owner);
|
||||
if (method === 'browser.deep_capture.detach') return detachDeepCapture(target, owner);
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.transform.')) {
|
||||
if (method === 'browser.transform.profile.list') {
|
||||
const profiles = await listBrowserTransformProfiles();
|
||||
const visible = await Promise.all(profiles.map(async (profile) => {
|
||||
try {
|
||||
await allowedTarget(grant, profile.target);
|
||||
return profile;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}));
|
||||
return visible.filter(Boolean);
|
||||
}
|
||||
if (method === 'browser.transform.profile.save') {
|
||||
const profileInput = input as unknown as BrowserTransformProfileInput;
|
||||
const target = await allowedTarget(grant, profileInput.target);
|
||||
const grantedTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
|
||||
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
|
||||
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
|
||||
}
|
||||
return saveBrowserTransformProfile({ ...profileInput, target });
|
||||
}
|
||||
if (method === 'browser.transform.profile.delete') {
|
||||
const profile = await getBrowserTransformProfile(String(input.id || ''));
|
||||
await allowedTarget(grant, profile.target);
|
||||
return deleteBrowserTransformProfile(profile.id);
|
||||
}
|
||||
if (method === 'browser.transform.execute') {
|
||||
const executeInput = input as unknown as BrowserTransformExecuteInput;
|
||||
const profile = await getBrowserTransformProfile(executeInput.profileId);
|
||||
await allowedTarget(grant, profile.target);
|
||||
return executeBrowserTransform(executeInput);
|
||||
}
|
||||
if (method === 'browser.observe.clear') return clearPageObservations(target);
|
||||
if (method === 'browser.observe.stop') return stopPageObservation(target);
|
||||
}
|
||||
|
||||
if (method === 'browser.context') {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { UserAgentProfile } from '@/types/models';
|
||||
|
||||
export const BUILTIN_USER_AGENT_PROFILES: readonly UserAgentProfile[] = [
|
||||
{
|
||||
id: 'chrome-windows', name: 'Chrome / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'chrome-macos', name: 'Chrome / macOS', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'edge-windows', name: 'Edge / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0',
|
||||
},
|
||||
{
|
||||
id: 'firefox-windows', name: 'Firefox / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
|
||||
},
|
||||
{
|
||||
id: 'firefox-linux', name: 'Firefox / Linux', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0',
|
||||
},
|
||||
{
|
||||
id: 'safari-macos', name: 'Safari / macOS', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15',
|
||||
},
|
||||
{
|
||||
id: 'safari-iphone', name: 'Safari / iPhone', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
{
|
||||
id: 'safari-ipad', name: 'Safari / iPad', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
{
|
||||
id: 'chrome-android', name: 'Chrome / Android', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'googlebot', name: 'Googlebot', category: 'bot', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getUserAgentProfiles(custom: UserAgentProfile[]): UserAgentProfile[] {
|
||||
return [...BUILTIN_USER_AGENT_PROFILES.map((profile) => ({ ...profile })), ...custom.map((profile) => ({ ...profile }))];
|
||||
}
|
||||
@@ -2,19 +2,55 @@ import { vi, describe, expect, it } from 'vitest';
|
||||
|
||||
vi.mock('wxt/browser', () => ({ browser: { declarativeNetRequest: {} } }));
|
||||
|
||||
import { buildUserAgentDnrRules } from './user-agent';
|
||||
import {
|
||||
buildUserAgentDnrRules, resolveUserAgent, userAgentHostname, validateUserAgent,
|
||||
} from './user-agent';
|
||||
import { BUILTIN_USER_AGENT_PROFILES } from './user-agent-profiles';
|
||||
import type { UserAgentAssignment, UserAgentProfile } from '@/types/models';
|
||||
|
||||
describe('User-Agent DNR rules', () => {
|
||||
it('normalizes domains and covers browser request resource types', () => {
|
||||
const [rule] = buildUserAgentDnrRules([{
|
||||
id: 'ua-1', name: 'Test', enabled: true, userAgent: 'Yakit-E2E/1.0', domains: ['https://*.example.test/path'],
|
||||
}]);
|
||||
expect(rule.condition.urlFilter).toBe('||example.test^');
|
||||
const assignment: UserAgentAssignment = {
|
||||
id: 'assignment-1', hostname: 'app.example.test', profileId: 'chrome-windows', createdAt: 1, updatedAt: 2,
|
||||
};
|
||||
|
||||
describe('User-Agent site assignments', () => {
|
||||
it('compiles one real request-header rule for each hostname', () => {
|
||||
const [rule] = buildUserAgentDnrRules([assignment]);
|
||||
expect(rule.condition.urlFilter).toBe('||app.example.test^');
|
||||
expect(rule.condition.resourceTypes).toContain('websocket');
|
||||
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set', value: 'Yakit-E2E/1.0' }] });
|
||||
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set' }] });
|
||||
});
|
||||
|
||||
it('ignores disabled rules', () => {
|
||||
expect(buildUserAgentDnrRules([{ id: 'x', name: 'X', enabled: false, userAgent: 'x', domains: [] }])).toHaveLength(0);
|
||||
it('deduplicates a hostname and ignores missing profiles', () => {
|
||||
const rules = buildUserAgentDnrRules([
|
||||
assignment,
|
||||
{ ...assignment, id: 'assignment-2', profileId: 'safari-iphone', updatedAt: 3 },
|
||||
{ ...assignment, id: 'missing', hostname: 'missing.example.test', profileId: 'missing' },
|
||||
]);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].action).toMatchObject({
|
||||
requestHeaders: [{ value: BUILTIN_USER_AGENT_PROFILES.find((item) => item.id === 'safari-iphone')!.userAgent }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the effective profile for the current URL', () => {
|
||||
expect(resolveUserAgent('https://app.example.test/path', [assignment], [], 'Browser/Default'))
|
||||
.toMatchObject({ hostname: 'app.example.test', mode: 'override', profile: { id: 'chrome-windows' } });
|
||||
expect(resolveUserAgent('https://other.example.test/', [assignment], [], 'Browser/Default'))
|
||||
.toEqual({ hostname: 'other.example.test', mode: 'default', userAgent: 'Browser/Default' });
|
||||
});
|
||||
|
||||
it('supports custom profiles and rejects unsafe header values', () => {
|
||||
const custom: UserAgentProfile = {
|
||||
id: 'custom-1', name: 'Custom', userAgent: 'Yakit-Test/1.0', category: 'custom', builtin: false,
|
||||
};
|
||||
expect(buildUserAgentDnrRules([{ ...assignment, profileId: custom.id }], [custom])[0].action)
|
||||
.toMatchObject({ requestHeaders: [{ value: 'Yakit-Test/1.0' }] });
|
||||
expect(validateUserAgent(' Safe-UA/1.0 ')).toBe('Safe-UA/1.0');
|
||||
expect(() => validateUserAgent('Injected\r\nX-Test: yes')).toThrow('换行');
|
||||
});
|
||||
|
||||
it('only accepts HTTP(S) targets', () => {
|
||||
expect(userAgentHostname('http://127.0.0.1:8080/path')).toBe('127.0.0.1');
|
||||
expect(() => userAgentHostname('chrome://extensions')).toThrow('HTTP');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,84 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { UserAgentRule } from '@/types/models';
|
||||
import type {
|
||||
UserAgentAssignment, UserAgentProfile, UserAgentResolution,
|
||||
} from '@/types/models';
|
||||
import { getUserAgentProfiles } from './user-agent-profiles';
|
||||
|
||||
const RULE_ID_BASE = 20_000;
|
||||
const MAX_UA_RULES = 5_000;
|
||||
const MAX_UA_ASSIGNMENTS = 5_000;
|
||||
|
||||
function domainFilter(domain: string): string {
|
||||
const normalized = domain.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
|
||||
return normalized ? `||${normalized}^` : '*';
|
||||
function domainFilter(hostname: string): string {
|
||||
return `||${hostname}^`;
|
||||
}
|
||||
|
||||
export function buildUserAgentDnrRules(rules: UserAgentRule[]): Browser.declarativeNetRequest.Rule[] {
|
||||
const addRules: Browser.declarativeNetRequest.Rule[] = [];
|
||||
let nextRuleId = RULE_ID_BASE;
|
||||
for (const rule of rules.filter((item) => item.enabled)) {
|
||||
const domains = rule.domains.length > 0 ? [...new Set(rule.domains)] : [''];
|
||||
for (const domain of domains) {
|
||||
if (nextRuleId >= RULE_ID_BASE + MAX_UA_RULES) {
|
||||
throw new Error(`User-Agent 动态规则超过 ${MAX_UA_RULES} 条限制`);
|
||||
}
|
||||
addRules.push({
|
||||
id: nextRuleId,
|
||||
priority: nextRuleId - RULE_ID_BASE + 1,
|
||||
action: {
|
||||
type: 'modifyHeaders',
|
||||
requestHeaders: [{ header: 'user-agent', operation: 'set', value: rule.userAgent }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: domainFilter(domain),
|
||||
resourceTypes: [
|
||||
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
|
||||
'font', 'media', 'websocket', 'other',
|
||||
],
|
||||
},
|
||||
});
|
||||
nextRuleId += 1;
|
||||
}
|
||||
}
|
||||
return addRules;
|
||||
export function userAgentHostname(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('User-Agent 只能应用到 HTTP(S) 页面');
|
||||
return parsed.hostname.toLowerCase();
|
||||
}
|
||||
|
||||
export async function applyUserAgentRules(rules: UserAgentRule[]): Promise<void> {
|
||||
export function validateUserAgent(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) throw new Error('User-Agent 不能为空');
|
||||
if (normalized.length > 1_024) throw new Error('User-Agent 不能超过 1024 个字符');
|
||||
if (/\r|\n/.test(normalized)) throw new Error('User-Agent 不能包含换行符');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveUserAgent(
|
||||
url: string,
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[],
|
||||
browserDefault = globalThis.navigator?.userAgent || '',
|
||||
): UserAgentResolution {
|
||||
const hostname = userAgentHostname(url);
|
||||
const assignment = assignments.find((item) => item.hostname === hostname);
|
||||
const profile = assignment
|
||||
? getUserAgentProfiles(customProfiles).find((item) => item.id === assignment.profileId)
|
||||
: undefined;
|
||||
if (!assignment || !profile) return { hostname, mode: 'default', userAgent: browserDefault };
|
||||
return { hostname, mode: 'override', userAgent: profile.userAgent, profile, assignment };
|
||||
}
|
||||
|
||||
export function buildUserAgentDnrRules(
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[] = [],
|
||||
): Browser.declarativeNetRequest.Rule[] {
|
||||
const profiles = new Map(getUserAgentProfiles(customProfiles).map((profile) => [profile.id, profile]));
|
||||
const uniqueAssignments = new Map(assignments.map((assignment) => [assignment.hostname, assignment]));
|
||||
const active = [...uniqueAssignments.values()]
|
||||
.filter((assignment) => profiles.has(assignment.profileId))
|
||||
.sort((left, right) => left.hostname.localeCompare(right.hostname));
|
||||
if (active.length > MAX_UA_ASSIGNMENTS) throw new Error(`User-Agent 站点绑定超过 ${MAX_UA_ASSIGNMENTS} 条限制`);
|
||||
return active.map((assignment, index) => {
|
||||
const profile = profiles.get(assignment.profileId)!;
|
||||
return {
|
||||
id: RULE_ID_BASE + index,
|
||||
priority: 1_000 + assignment.hostname.split('.').length,
|
||||
action: {
|
||||
type: 'modifyHeaders',
|
||||
requestHeaders: [{ header: 'user-agent', operation: 'set', value: validateUserAgent(profile.userAgent) }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: domainFilter(assignment.hostname),
|
||||
resourceTypes: [
|
||||
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
|
||||
'font', 'media', 'websocket', 'other',
|
||||
],
|
||||
},
|
||||
} satisfies Browser.declarativeNetRequest.Rule;
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyUserAgentAssignments(
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[] = [],
|
||||
): Promise<void> {
|
||||
const oldRuleIds = (await browser.declarativeNetRequest.getDynamicRules())
|
||||
.map((rule) => rule.id)
|
||||
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_BASE + 10_000);
|
||||
|
||||
await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, addRules: buildUserAgentDnrRules(rules) });
|
||||
await browser.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: oldRuleIds,
|
||||
addRules: buildUserAgentDnrRules(assignments, customProfiles),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observationAnalysisWindow } from '@/features/page-observation/service';
|
||||
import { recordingAnalysisWindow } from '@/features/browser-recording/service';
|
||||
import type { BrowserTarget } from '@/types/models';
|
||||
import { exportNetworkRequest, listNetworkRequests } from './service';
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function capturedRequestEnginePayload(target: BrowserTarget, id: st
|
||||
rawRequestBase64: exported.rawRequestBase64,
|
||||
isHttps: exported.isHttps,
|
||||
observations: includeObservations && record
|
||||
? await observationAnalysisWindow(target, record.startedAt)
|
||||
? await recordingAnalysisWindow(target, record.startedAt)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_CALLABLE_REGISTRY_KEY = '__YAKIT_PAGE_CALLABLES_V2__' as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { callableExecutionPolicy, settleCallableResult } from './execution';
|
||||
|
||||
describe('page callable execution contract', () => {
|
||||
it('settles a declared Promise result', async () => {
|
||||
await expect(settleCallableResult(
|
||||
Promise.resolve({ signature: 'signed' }),
|
||||
callableExecutionPolicy('promise'),
|
||||
)).resolves.toEqual({ signature: 'signed' });
|
||||
});
|
||||
|
||||
it('supports auto mode for captured business functions', async () => {
|
||||
await expect(settleCallableResult('ciphertext', callableExecutionPolicy('auto'))).resolves.toBe('ciphertext');
|
||||
await expect(settleCallableResult(Promise.resolve('ciphertext'), callableExecutionPolicy('auto'))).resolves.toBe('ciphertext');
|
||||
});
|
||||
|
||||
it('fails closed when an asynchronous result exceeds its deadline', async () => {
|
||||
const never = new Promise(() => undefined);
|
||||
await expect(settleCallableResult(never, callableExecutionPolicy('promise', 250)))
|
||||
.rejects.toThrow('页面函数异步执行超过 250 ms');
|
||||
});
|
||||
|
||||
it('rejects a result that violates its declared mode', async () => {
|
||||
await expect(settleCallableResult(Promise.resolve('late'), callableExecutionPolicy('sync')))
|
||||
.rejects.toThrow('声明为同步执行');
|
||||
await expect(settleCallableResult('early', callableExecutionPolicy('promise')))
|
||||
.rejects.toThrow('声明为异步执行');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
BrowserPageCallableExecutionPolicy,
|
||||
BrowserPageCallableResultMode,
|
||||
} from '@/types/models';
|
||||
|
||||
export const DEFAULT_CALLABLE_TIMEOUT_MS = 8_000;
|
||||
export const MIN_CALLABLE_TIMEOUT_MS = 250;
|
||||
export const MAX_CALLABLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function callableExecutionPolicy(
|
||||
resultMode: BrowserPageCallableResultMode,
|
||||
timeoutMs = DEFAULT_CALLABLE_TIMEOUT_MS,
|
||||
): BrowserPageCallableExecutionPolicy {
|
||||
return {
|
||||
resultMode,
|
||||
timeoutMs: Math.max(MIN_CALLABLE_TIMEOUT_MS, Math.min(MAX_CALLABLE_TIMEOUT_MS, Math.floor(timeoutMs))),
|
||||
};
|
||||
}
|
||||
|
||||
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
||||
return Boolean(value && (typeof value === 'object' || typeof value === 'function')
|
||||
&& typeof (value as { then?: unknown }).then === 'function');
|
||||
}
|
||||
|
||||
export async function settleCallableResult(
|
||||
value: unknown,
|
||||
execution: BrowserPageCallableExecutionPolicy,
|
||||
): Promise<unknown> {
|
||||
const thenable = isThenable(value);
|
||||
if (execution.resultMode === 'sync') {
|
||||
if (thenable) throw new Error('页面函数声明为同步执行,但返回了 Promise');
|
||||
return value;
|
||||
}
|
||||
if (execution.resultMode === 'promise' && !thenable) {
|
||||
throw new Error('页面函数声明为异步执行,但没有返回 Promise');
|
||||
}
|
||||
if (!thenable) return value;
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(`页面函数异步执行超过 ${execution.timeoutMs} ms`));
|
||||
}, execution.timeoutMs);
|
||||
Promise.resolve(value).then(
|
||||
(result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
resolve(result);
|
||||
},
|
||||
(reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
reject(reason);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { requestMatchesTransaction, validateRequestTransactionOutput } from './request-transaction'
|
||||
|
||||
const transaction: BrowserPageCallableTransaction = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'encrypt/aesrsa.php?mode=login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
|
||||
}
|
||||
|
||||
describe('request transaction contract', () => {
|
||||
it('matches a relative recorded URL against the exact page request', () => {
|
||||
expect(requestMatchesTransaction(
|
||||
transaction,
|
||||
'post',
|
||||
'http://127.0.0.1:82/login/encrypt/aesrsa.php?mode=login',
|
||||
'http://127.0.0.1:82/login/index.html',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects another method, origin, path, or query', () => {
|
||||
const base = 'http://127.0.0.1:82/'
|
||||
expect(requestMatchesTransaction(transaction, 'GET', transaction.request.url, base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', 'https://example.test/encrypt/aesrsa.php?mode=login', base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', '/encrypt/rsa.php?mode=login', base)).toBe(false)
|
||||
expect(requestMatchesTransaction(transaction, 'POST', '/encrypt/aesrsa.php?mode=other', base)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a complete multi-field envelope and fails closed on a missing field', () => {
|
||||
const envelope = {
|
||||
encryptedData: 'ciphertext',
|
||||
encryptedKey: 'wrapped-key',
|
||||
encryptedIv: 'wrapped-iv',
|
||||
}
|
||||
expect(() => validateRequestTransactionOutput(envelope, transaction.request.expectedDestinations)).not.toThrow()
|
||||
expect(() => validateRequestTransactionOutput(
|
||||
{...envelope, encryptedIv: undefined},
|
||||
transaction.request.expectedDestinations,
|
||||
)).toThrow('截获的请求缺少目标字段:body.encryptedIv')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
||||
const MAX_CONTROLS = 2_000
|
||||
const MAX_FIELDS = 64
|
||||
const MAX_MUTATIONS = 2_000
|
||||
const DEFAULT_TIMEOUT_MS = 4_000
|
||||
|
||||
interface CapturedRequest {
|
||||
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
bodyText: string
|
||||
}
|
||||
|
||||
interface TransactionContext {
|
||||
domInputCount: number
|
||||
}
|
||||
|
||||
export interface RequestTransactionInvocation {
|
||||
transaction: BrowserPageCallableTransaction
|
||||
logicalInput: unknown
|
||||
invoke(context: TransactionContext): unknown
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
interface RollbackController {
|
||||
finish(): number
|
||||
}
|
||||
|
||||
interface MutableControl extends Element {
|
||||
value?: string
|
||||
checked?: boolean
|
||||
selectedIndex?: number
|
||||
name?: string
|
||||
id: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
function error(message: string): Error {
|
||||
return new Error(`请求事务失败:${message}`)
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function absoluteUrl(value: string): string {
|
||||
try { return new URL(value, location.href).toString() } catch { return value }
|
||||
}
|
||||
|
||||
function runtimeBaseUrl(): string {
|
||||
return typeof location === 'undefined' ? 'http://localhost/' : location.href
|
||||
}
|
||||
|
||||
function comparableUrl(value: string, baseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(value, baseUrl)
|
||||
return `${url.origin}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function requestMatchesTransaction(
|
||||
transaction: BrowserPageCallableTransaction,
|
||||
method: string,
|
||||
url: string,
|
||||
baseUrl = runtimeBaseUrl(),
|
||||
): boolean {
|
||||
return transaction.request.method.toUpperCase() === method.toUpperCase()
|
||||
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
async function bodyText(value: unknown): Promise<string> {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return value.toString()
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.text()
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
||||
const form = new URLSearchParams()
|
||||
for (const [key, item] of value.entries()) {
|
||||
if (typeof item !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
form.append(key, item)
|
||||
}
|
||||
return form.toString()
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value))
|
||||
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
|
||||
throw error(`不支持的请求 Body 类型 ${Object.prototype.toString.call(value)}`)
|
||||
}
|
||||
|
||||
function headerRecord(headers: Headers): Record<string, string> {
|
||||
const output: Record<string, string> = Object.create(null) as Record<string, string>
|
||||
headers.forEach((value, key) => { output[key.toLowerCase()] = value })
|
||||
return output
|
||||
}
|
||||
|
||||
function parseForm(value: string): Record<string, string | string[]> {
|
||||
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
||||
for (const [key, item] of new URLSearchParams(value)) {
|
||||
const previous = output[key]
|
||||
output[key] = previous === undefined ? item : Array.isArray(previous) ? [...previous, item] : [previous, item]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function capturedBody(request: CapturedRequest): unknown {
|
||||
const contentType = request.headers['content-type']?.toLowerCase() || ''
|
||||
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) {
|
||||
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
|
||||
}
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return parseForm(request.bodyText)
|
||||
return request.bodyText
|
||||
}
|
||||
|
||||
function readOwnPath(input: unknown, path: string): unknown {
|
||||
let current = input
|
||||
for (const segment of path.split('.').filter(Boolean)) {
|
||||
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function validateRequestTransactionOutput(value: unknown, destinations: string[]): void {
|
||||
const missing = destinations.filter((destination) => {
|
||||
const path = destination === 'body' ? '' : destination.startsWith('body.') ? destination.slice(5) : destination
|
||||
return path ? readOwnPath(value, path) === undefined : value === undefined
|
||||
})
|
||||
if (missing.length) throw error(`截获的请求缺少目标字段:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
function logicalObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let current = value
|
||||
if (typeof current === 'string') {
|
||||
try { current = JSON.parse(current) as unknown } catch { return undefined }
|
||||
}
|
||||
return current && typeof current === 'object' && !Array.isArray(current)
|
||||
? current as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
interface LogicalField {
|
||||
path: string
|
||||
key: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function logicalFields(value: unknown): LogicalField[] {
|
||||
const root = logicalObject(value)
|
||||
if (!root) return []
|
||||
const output: LogicalField[] = []
|
||||
const visit = (current: Record<string, unknown>, prefix: string, depth: number) => {
|
||||
if (depth > 4 || output.length >= MAX_FIELDS) return
|
||||
for (const [key, item] of Object.entries(current)) {
|
||||
if (output.length >= MAX_FIELDS) break
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) visit(item as Record<string, unknown>, path, depth + 1)
|
||||
else output.push({ path, key, value: item })
|
||||
}
|
||||
}
|
||||
visit(root, '', 0)
|
||||
return output
|
||||
}
|
||||
|
||||
function controlNames(control: MutableControl): string[] {
|
||||
const name = typeof control.name === 'string' ? control.name : ''
|
||||
return [name, control.id, name.replace(/\[([^\]]+)\]/g, '.$1')].filter(Boolean)
|
||||
}
|
||||
|
||||
function setControlValue(control: MutableControl, value: unknown): void {
|
||||
const type = String(control.type || '').toLowerCase()
|
||||
if ((type === 'checkbox' || type === 'radio') && typeof control.checked === 'boolean') {
|
||||
if (type === 'radio') control.checked = String(control.value ?? '') === String(value)
|
||||
else control.checked = typeof value === 'boolean' ? value : Array.isArray(value)
|
||||
? value.map(String).includes(String(control.value ?? ''))
|
||||
: Boolean(value)
|
||||
return
|
||||
}
|
||||
if ('value' in control) {
|
||||
control.value = value === undefined || value === null ? ''
|
||||
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function bindLogicalInput(value: unknown): number {
|
||||
const fields = logicalFields(value)
|
||||
if (!fields.length) return 0
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const missing: string[] = []
|
||||
let matched = 0
|
||||
for (const field of fields) {
|
||||
const candidates = controls.filter((control) => controlNames(control).some((name) => (
|
||||
name === field.path || name === field.key || name.endsWith(`.${field.path}`) || name.endsWith(`.${field.key}`)
|
||||
)))
|
||||
if (!candidates.length) {
|
||||
missing.push(field.path)
|
||||
continue
|
||||
}
|
||||
candidates.forEach((control) => setControlValue(control, field.value))
|
||||
matched += 1
|
||||
}
|
||||
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
||||
return matched
|
||||
}
|
||||
|
||||
function beginDomRollback(): RollbackController {
|
||||
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
||||
const controlSnapshots = controls.map((control) => ({
|
||||
control,
|
||||
value: control.value,
|
||||
checked: control.checked,
|
||||
selectedIndex: control.selectedIndex,
|
||||
}))
|
||||
const mutations: MutationRecord[] = []
|
||||
const root = document.documentElement
|
||||
const observer = root && typeof MutationObserver !== 'undefined'
|
||||
? new MutationObserver((records) => {
|
||||
if (mutations.length < MAX_MUTATIONS) mutations.push(...records.slice(0, MAX_MUTATIONS - mutations.length))
|
||||
})
|
||||
: undefined
|
||||
observer?.observe(root, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
})
|
||||
let finished = false
|
||||
return {
|
||||
finish() {
|
||||
if (finished) return mutations.length
|
||||
finished = true
|
||||
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
||||
observer?.disconnect()
|
||||
for (const snapshot of controlSnapshots) {
|
||||
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
|
||||
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
||||
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
||||
}
|
||||
for (const mutation of [...mutations].reverse()) {
|
||||
try {
|
||||
if (mutation.type === 'attributes') {
|
||||
if (!mutation.attributeName) continue
|
||||
if (mutation.oldValue === null) (mutation.target as Element).removeAttributeNS(mutation.attributeNamespace, mutation.attributeName)
|
||||
else (mutation.target as Element).setAttributeNS(mutation.attributeNamespace, mutation.attributeName, mutation.oldValue)
|
||||
} else if (mutation.type === 'characterData') {
|
||||
mutation.target.nodeValue = mutation.oldValue
|
||||
} else {
|
||||
mutation.addedNodes.forEach((node) => { if (node.parentNode === mutation.target) mutation.target.removeChild(node) })
|
||||
const before = mutation.nextSibling?.parentNode === mutation.target ? mutation.nextSibling : null
|
||||
mutation.removedNodes.forEach((node) => mutation.target.insertBefore(node, before))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort rollback is followed by fail-closed validation at the request boundary.
|
||||
}
|
||||
}
|
||||
return mutations.length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setMethod<T extends object, K extends keyof T>(target: T, key: K, value: T[K], restorers: Array<() => void>): void {
|
||||
const previous = target[key]
|
||||
try {
|
||||
target[key] = value
|
||||
restorers.push(() => { target[key] = previous })
|
||||
} catch {
|
||||
// A non-writable optional boundary remains protected by the other installed boundaries.
|
||||
}
|
||||
}
|
||||
|
||||
function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): CapturedRequest {
|
||||
const method = (form.method || 'GET').toUpperCase()
|
||||
const url = absoluteUrl(form.action || location.href)
|
||||
const formData = new FormData(form, submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement ? submitter : undefined)
|
||||
const encoded = new URLSearchParams()
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (typeof value !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
||||
encoded.append(key, value)
|
||||
}
|
||||
return {
|
||||
boundary: 'form', method, url,
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
bodyText: encoded.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
||||
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let captured: CapturedRequest | undefined
|
||||
let captureFailure: Error | undefined
|
||||
let resolveCapture!: () => void
|
||||
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
||||
|
||||
const capture = async (request: CapturedRequest): Promise<void> => {
|
||||
if (captured || captureFailure) {
|
||||
captureFailure = error('页面流程产生了多个网络请求,无法唯一确定转换边界')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (!requestMatchesTransaction(input.transaction, request.method, request.url)) {
|
||||
captureFailure = error(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
||||
captureFailure = error('页面生成的请求 Body 超过 8 MiB')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
}
|
||||
captured = request
|
||||
resolveCapture()
|
||||
}
|
||||
|
||||
const previousFetch = window.fetch
|
||||
setMethod(window, 'fetch', (async function transactionFetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = new Request(requestInput, init)
|
||||
await capture({
|
||||
boundary: 'fetch',
|
||||
method: request.method.toUpperCase(),
|
||||
url: request.url,
|
||||
headers: headerRecord(request.headers),
|
||||
bodyText: await request.clone().text(),
|
||||
})
|
||||
return new Response(JSON.stringify({ success: false, error: 'request captured by Yakit Browser Agent' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof previousFetch, restorers)
|
||||
|
||||
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
|
||||
const xhrPrototype = XMLHttpRequest.prototype
|
||||
const previousOpen = xhrPrototype.open
|
||||
const previousSetHeader = xhrPrototype.setRequestHeader
|
||||
const previousSend = xhrPrototype.send
|
||||
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
||||
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
|
||||
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
|
||||
}) as typeof previousOpen, restorers)
|
||||
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (metadata) metadata.headers[name.toLowerCase()] = value
|
||||
return Reflect.apply(previousSetHeader, this, [name, value])
|
||||
}) as typeof previousSetHeader, restorers)
|
||||
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
const metadata = xhrMetadata.get(this)
|
||||
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
||||
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
}) as typeof previousSend, restorers)
|
||||
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
||||
void bodyText(data).then((text) => capture({
|
||||
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
|
||||
})).catch((reason) => {
|
||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||
resolveCapture()
|
||||
})
|
||||
return true
|
||||
}) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
|
||||
const formPrototype = HTMLFormElement.prototype
|
||||
const previousSubmit = formPrototype.submit
|
||||
const previousRequestSubmit = formPrototype.requestSubmit
|
||||
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
|
||||
void capture(formRequest(this)).catch(() => undefined)
|
||||
}) as typeof previousSubmit, restorers)
|
||||
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
|
||||
void capture(formRequest(this, submitter)).catch(() => undefined)
|
||||
}) as typeof previousRequestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
|
||||
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
|
||||
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
|
||||
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
|
||||
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
|
||||
|
||||
let invocationFailure: unknown
|
||||
let returned: unknown
|
||||
try {
|
||||
const domInputCount = bindLogicalInput(input.logicalInput)
|
||||
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
||||
invocationFailure = reason
|
||||
resolveCapture()
|
||||
}
|
||||
void Promise.resolve(returned).catch((reason) => {
|
||||
invocationFailure = reason
|
||||
if (!captured) resolveCapture()
|
||||
})
|
||||
await Promise.race([
|
||||
captureSignal,
|
||||
delay(timeoutMs).then(() => {
|
||||
if (!captured && !captureFailure) captureFailure = error('等待页面生成目标请求超时')
|
||||
}),
|
||||
])
|
||||
if (captureFailure) throw captureFailure
|
||||
if (!captured) {
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
throw error('页面函数没有产生目标请求')
|
||||
}
|
||||
await delay(0)
|
||||
if (captureFailure) throw captureFailure
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
const value = capturedBody(captured)
|
||||
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSideEffectFreeCallable(
|
||||
invoke: () => unknown,
|
||||
execution: BrowserPageCallableExecutionPolicy,
|
||||
): Promise<unknown> {
|
||||
const rollback = beginDomRollback()
|
||||
const restorers: Array<() => void> = []
|
||||
let attemptedBoundary = ''
|
||||
const block = (boundary: string): never => {
|
||||
attemptedBoundary = boundary
|
||||
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
|
||||
}
|
||||
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
|
||||
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
|
||||
}
|
||||
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
|
||||
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
|
||||
const submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
attemptedBoundary = 'Form Submit'
|
||||
}
|
||||
document.addEventListener('submit', submitListener, true)
|
||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||
try {
|
||||
const value = await settleCallableResult(invoke(), execution)
|
||||
await Promise.resolve()
|
||||
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
||||
const mutationCount = rollback.finish()
|
||||
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
||||
return value
|
||||
} finally {
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
rollback.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserPageCallable } from '@/types/models';
|
||||
import { normalizeCallable } from './service';
|
||||
|
||||
const target = { tabId: 7, frameId: 0, documentId: 'document-1' };
|
||||
const callable: Omit<BrowserPageCallable, 'target'> = {
|
||||
id: 'transaction-1',
|
||||
name: '登录请求业务封装',
|
||||
kind: 'request-transaction',
|
||||
operation: 'buildLoginEnvelope',
|
||||
origin: 'https://example.test',
|
||||
lifecycle: 'document',
|
||||
execution: { resultMode: 'auto', timeoutMs: 10_000 },
|
||||
inputSlots: [{ id: 'body', name: 'body', index: 0, role: 'data', dataType: 'object', required: true, retained: false }],
|
||||
output: {
|
||||
dataType: 'object', encoding: 'json', shape: 'envelope',
|
||||
paths: ['body.encryptedData', 'body.encryptedKey'],
|
||||
},
|
||||
transaction: {
|
||||
request: {
|
||||
method: 'POST', url: 'https://example.test/login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr'],
|
||||
},
|
||||
provenance: { eventId: 'request-1' },
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe('page callable metadata contract', () => {
|
||||
it('accepts an explicit asynchronous multi-output envelope', () => {
|
||||
expect(normalizeCallable(callable, target)).toMatchObject({
|
||||
target,
|
||||
execution: { resultMode: 'auto', timeoutMs: 10_000 },
|
||||
output: { shape: 'envelope', paths: ['body.encryptedData', 'body.encryptedKey'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a transaction whose declared envelope differs from its request boundary', () => {
|
||||
expect(normalizeCallable({
|
||||
...callable,
|
||||
output: { ...callable.output, paths: ['body.encryptedData'] },
|
||||
}, target)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects missing execution policy instead of silently selecting legacy behavior', () => {
|
||||
const { execution: _execution, ...legacy } = callable;
|
||||
expect(normalizeCallable(legacy, target)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
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 { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
||||
import {
|
||||
MAX_CALLABLE_TIMEOUT_MS,
|
||||
MIN_CALLABLE_TIMEOUT_MS,
|
||||
callableExecutionPolicy,
|
||||
} from './execution';
|
||||
|
||||
const MAX_CALLABLES = 128;
|
||||
|
||||
type RawCallable = Omit<BrowserPageCallable, 'target'> & { target?: BrowserTarget };
|
||||
|
||||
function normalizeTransaction(value: unknown): BrowserPageCallable['transaction'] {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<NonNullable<BrowserPageCallable['transaction']>>;
|
||||
const request = input.request as Partial<NonNullable<BrowserPageCallable['transaction']>['request']> | undefined;
|
||||
if (!request || 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())) return undefined;
|
||||
const boundaries = Array.isArray(input.boundaries)
|
||||
? input.boundaries.filter((item): item is NonNullable<BrowserPageCallable['transaction']>['boundaries'][number] => (
|
||||
['fetch', 'xhr', 'beacon', 'form'].includes(String(item))
|
||||
)).slice(0, 4)
|
||||
: ['fetch', 'xhr', 'beacon', 'form'] as NonNullable<BrowserPageCallable['transaction']>['boundaries'];
|
||||
if (!boundaries.length) return undefined;
|
||||
return {
|
||||
request: {
|
||||
method: request.method.toUpperCase().slice(0, 16),
|
||||
url: request.url.slice(0, 4_096),
|
||||
expectedDestinations: request.expectedDestinations.slice(0, 64).map((item) => item.slice(0, 512)),
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExecution(value: unknown): BrowserPageCallable['execution'] | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<BrowserPageCallable['execution']>;
|
||||
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 normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCallable | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<RawCallable>;
|
||||
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<BrowserPageCallable['inputSlots'][number]>;
|
||||
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,
|
||||
},
|
||||
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const controller = (window as unknown as Record<string, unknown>)[registryKey] as {
|
||||
version?: unknown;
|
||||
command?: (name: PageControllerCommand, params: Record<string, unknown>) => unknown;
|
||||
} | undefined;
|
||||
if (controller?.version !== protocolVersion || typeof controller.command !== 'function') {
|
||||
if (command === 'callable.list') return [];
|
||||
throw new Error('页面函数控制器不存在,页面可能已经刷新');
|
||||
}
|
||||
return await Promise.resolve(controller.command(command, input));
|
||||
}
|
||||
|
||||
async function callPageController(
|
||||
target: BrowserTarget,
|
||||
command: PageControllerCommand,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
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));
|
||||
}
|
||||
return result?.result;
|
||||
}
|
||||
|
||||
export async function listPageCallables(target: BrowserTarget): Promise<BrowserPageCallable[]> {
|
||||
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<BrowserPageCallableExecution> {
|
||||
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<BrowserPageCallable[]> {
|
||||
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<BrowserTransformExecution> {
|
||||
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 };
|
||||
@@ -1,217 +0,0 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import { scriptingTarget } from '@/platform/browser/targets';
|
||||
import type {
|
||||
BrowserTarget, PageObservationOptions, PageObservationRecord, PageObservationStatus,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
const OBSERVER_SCRIPT = '/page-observer-main-world.js' as const;
|
||||
const DEFAULT_OPTIONS: PageObservationOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
interface PageObserverSnapshot {
|
||||
version: 2;
|
||||
active: boolean;
|
||||
startedAt?: number;
|
||||
count: number;
|
||||
droppedCount: number;
|
||||
options?: PageObservationOptions;
|
||||
records: PageObservationRecord[];
|
||||
}
|
||||
|
||||
interface OwnedObservation {
|
||||
target: BrowserTarget;
|
||||
owner: { kind: 'local' } | { kind: 'grant'; grantId: string };
|
||||
}
|
||||
|
||||
type ObserverCommand = 'start' | 'status' | 'list' | 'clear' | 'stop';
|
||||
const ownedObservations = new Map<string, OwnedObservation>();
|
||||
|
||||
function targetKey(target: BrowserTarget): string {
|
||||
return `${target.tabId}:${target.frameId}`;
|
||||
}
|
||||
|
||||
function pageObserverCommand(command: ObserverCommand, input: Record<string, unknown>): unknown {
|
||||
const controller = (window as unknown as Record<string, unknown>).__YAKIT_PAGE_OBSERVER_V2__ as {
|
||||
version?: unknown;
|
||||
command?: (name: ObserverCommand, params: Record<string, unknown>) => unknown;
|
||||
} | undefined;
|
||||
if (controller?.version !== 2 || typeof controller.command !== 'function') {
|
||||
if (command === 'status') return { version: 2, active: false, count: 0, droppedCount: 0, records: [] };
|
||||
throw new Error('页面观测器未安装');
|
||||
}
|
||||
return controller.command(command, input);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
return typeof value === 'string' ? value.slice(0, maxLength) : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptions(input?: Partial<PageObservationOptions>): PageObservationOptions {
|
||||
return {
|
||||
captureValues: input?.captureValues === true,
|
||||
maxEntries: Math.max(10, Math.min(Math.floor(input?.maxEntries || DEFAULT_OPTIONS.maxEntries), MAX_ENTRIES)),
|
||||
maxValueBytes: Math.max(256, Math.min(Math.floor(input?.maxValueBytes || DEFAULT_OPTIONS.maxValueBytes), 8_192)),
|
||||
expiresAt: typeof input?.expiresAt === 'number' && Number.isFinite(input.expiresAt) ? input.expiresAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown, allowSensitive: boolean): PageObservationRecord | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const kinds = ['fetch', 'xhr', 'form', 'websocket', 'webcrypto', 'cryptojs'] as const;
|
||||
if (typeof input.id !== 'string' || !kinds.includes(input.kind as typeof kinds[number]) || typeof input.operation !== 'string') return undefined;
|
||||
const output = {
|
||||
id: input.id.slice(0, 160),
|
||||
sequence: Math.max(0, Math.floor(finiteNumber(input.sequence))),
|
||||
timestamp: finiteNumber(input.timestamp),
|
||||
kind: input.kind as PageObservationRecord['kind'],
|
||||
operation: input.operation.slice(0, 160),
|
||||
sensitiveCaptured: allowSensitive && input.sensitiveCaptured === true,
|
||||
} as PageObservationRecord & Record<string, unknown>;
|
||||
const stringLimits: Record<string, number> = {
|
||||
url: 8_192, method: 32, algorithm: 240, socketId: 160, dataType: 120,
|
||||
stack: 4_096, scriptUrl: 2_048, error: 512,
|
||||
};
|
||||
for (const [key, limit] of Object.entries(stringLimits)) {
|
||||
const normalized = optionalString(input[key], limit);
|
||||
if (normalized !== undefined) output[key] = normalized;
|
||||
}
|
||||
if (input.direction === 'send' || input.direction === 'receive') output.direction = input.direction;
|
||||
for (const key of ['byteLength', 'resultByteLength'] as const) {
|
||||
if (input[key] !== undefined) output[key] = Math.max(0, finiteNumber(input[key]));
|
||||
}
|
||||
if (allowSensitive) {
|
||||
output.inputPreview = optionalString(input.inputPreview, 8_192);
|
||||
output.outputPreview = optionalString(input.outputPreview, 8_192);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeSnapshot(value: unknown, allowSensitive: boolean): PageObserverSnapshot {
|
||||
if (!value || typeof value !== 'object') throw new ExtensionError('observer_unavailable', '页面观测器返回了无效状态');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (input.version !== 2 || typeof input.active !== 'boolean' || !Array.isArray(input.records)) {
|
||||
throw new ExtensionError('observer_unavailable', '页面观测器协议不兼容');
|
||||
}
|
||||
const pageOptions = input.options && typeof input.options === 'object'
|
||||
? normalizeOptions(input.options as Partial<PageObservationOptions>)
|
||||
: undefined;
|
||||
return {
|
||||
version: 2,
|
||||
active: input.active,
|
||||
startedAt: input.startedAt === undefined ? undefined : finiteNumber(input.startedAt),
|
||||
count: Math.max(0, Math.floor(finiteNumber(input.count))),
|
||||
droppedCount: Math.max(0, Math.floor(finiteNumber(input.droppedCount))),
|
||||
options: pageOptions,
|
||||
records: input.records.slice(-MAX_ENTRIES).map((item) => normalizeRecord(item, allowSensitive)).filter((item): item is PageObservationRecord => Boolean(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCommand(
|
||||
target: BrowserTarget,
|
||||
command: ObserverCommand,
|
||||
input: Record<string, unknown> = {},
|
||||
allowSensitive = false,
|
||||
): Promise<PageObserverSnapshot> {
|
||||
let results: Browser.scripting.InjectionResult[];
|
||||
try {
|
||||
results = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: pageObserverCommand,
|
||||
args: [command, input],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (results.length !== 1) throw new ExtensionError('observer_unavailable', '页面观测器无法唯一定位目标文档');
|
||||
return normalizeSnapshot(results[0].result, allowSensitive);
|
||||
}
|
||||
|
||||
async function install(target: BrowserTarget): Promise<void> {
|
||||
try {
|
||||
await browser.scripting.executeScript({ target: scriptingTarget(target), world: 'MAIN', files: [OBSERVER_SCRIPT] });
|
||||
} catch (error) {
|
||||
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function statusFrom(target: BrowserTarget, snapshot: PageObserverSnapshot): PageObservationStatus {
|
||||
return {
|
||||
active: snapshot.active,
|
||||
target,
|
||||
startedAt: snapshot.startedAt,
|
||||
count: snapshot.count,
|
||||
droppedCount: snapshot.droppedCount,
|
||||
options: snapshot.options,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPageObservation(
|
||||
target: BrowserTarget,
|
||||
input?: Partial<PageObservationOptions>,
|
||||
owner: OwnedObservation['owner'] = { kind: 'local' },
|
||||
): Promise<PageObservationStatus> {
|
||||
const options = normalizeOptions(input);
|
||||
await install(target);
|
||||
const snapshot = await executeCommand(target, 'start', { ...options }, options.captureValues);
|
||||
ownedObservations.set(targetKey(target), { target, owner });
|
||||
return statusFrom(target, snapshot);
|
||||
}
|
||||
|
||||
export async function pageObservationStatus(target: BrowserTarget): Promise<PageObservationStatus> {
|
||||
try {
|
||||
return statusFrom(target, await executeCommand(target, 'status'));
|
||||
} catch (error) {
|
||||
if (error instanceof ExtensionError && error.code === 'observer_unavailable') {
|
||||
return { active: false, target, count: 0, droppedCount: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPageObservations(target: BrowserTarget, limit = 100, allowSensitive = false): Promise<PageObservationRecord[]> {
|
||||
const snapshot = await executeCommand(target, 'list', { limit: Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)) }, allowSensitive);
|
||||
return snapshot.records;
|
||||
}
|
||||
|
||||
export async function clearPageObservations(target: BrowserTarget): Promise<PageObservationStatus> {
|
||||
return statusFrom(target, await executeCommand(target, 'clear'));
|
||||
}
|
||||
|
||||
export async function stopPageObservation(target: BrowserTarget): Promise<PageObservationStatus> {
|
||||
const snapshot = await executeCommand(target, 'stop').catch(() => undefined);
|
||||
ownedObservations.delete(targetKey(target));
|
||||
return snapshot ? statusFrom(target, snapshot) : { active: false, target, count: 0, droppedCount: 0 };
|
||||
}
|
||||
|
||||
export async function stopPageObservationsForGrant(grantId: string): Promise<void> {
|
||||
const matches = [...ownedObservations.values()].filter((item) => item.owner.kind === 'grant' && item.owner.grantId === grantId);
|
||||
await Promise.allSettled(matches.map((item) => stopPageObservation(item.target)));
|
||||
}
|
||||
|
||||
export async function observationAnalysisWindow(target: BrowserTarget, centerTimestamp: number): Promise<Array<Pick<
|
||||
PageObservationRecord,
|
||||
'kind' | 'operation' | 'algorithm' | 'direction' | 'scriptUrl' | 'byteLength' | 'resultByteLength' | 'timestamp'
|
||||
>>> {
|
||||
const records = await listPageObservations(target, MAX_ENTRIES, false).catch(() => []);
|
||||
return records.filter((item) => Math.abs(item.timestamp - centerTimestamp) <= 60_000).map((item) => ({
|
||||
kind: item.kind,
|
||||
operation: item.operation,
|
||||
algorithm: item.algorithm,
|
||||
direction: item.direction,
|
||||
scriptUrl: item.scriptUrl,
|
||||
byteLength: item.byteLength,
|
||||
resultByteLength: item.resultByteLength,
|
||||
timestamp: item.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
for (const [key, observation] of ownedObservations) if (observation.target.tabId === tabId) ownedObservations.delete(key);
|
||||
});
|
||||
@@ -1,36 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProxyProfile, ProxyRule } from '@/types/models';
|
||||
import { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
|
||||
import type {
|
||||
NormalizedProxyRule, ProxyProfile, ProxyRule, ProxyRuleSource,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
compileProxyRules, previewProxyRules, proxyConditionMatches, type ProxyCompilationInput,
|
||||
} from './compiler';
|
||||
|
||||
const profiles: ProxyProfile[] = [
|
||||
{ id: 'direct', name: 'Direct', kind: 'direct', bypass: [] },
|
||||
{ id: 'mitm', name: 'MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083, bypass: [] },
|
||||
];
|
||||
const rules: ProxyRule[] = [
|
||||
{ id: 'low', name: 'Low', enabled: true, patterns: ['*.example.test'], proxyProfileId: 'direct', priority: 10 },
|
||||
{ id: 'high', name: 'High', enabled: true, patterns: ['api.example.test'], proxyProfileId: 'mitm', priority: 20 },
|
||||
];
|
||||
|
||||
function manualRule(id: string, order: number, type: ProxyRule['condition']['type'], value: string, profileId = 'mitm'): ProxyRule {
|
||||
return {
|
||||
id, name: id, enabled: true, condition: { type, value }, proxyProfileId: profileId,
|
||||
order, createdAt: 1, updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function source(overrides: Partial<ProxyRuleSource> = {}): ProxyRuleSource {
|
||||
return {
|
||||
id: 'source', name: 'Source', url: 'https://example.test/rules.txt', format: 'hosts', enabled: true,
|
||||
matchProfileId: 'mitm', bypassProfileId: 'direct', order: 0, updateIntervalMinutes: 720,
|
||||
revision: 'revision-1', status: 'ready', totalRuleCount: 0, supportedRuleCount: 0,
|
||||
ignoredRuleCount: 0, invalidRuleCount: 0, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function compilationInput(
|
||||
manualRules: ProxyRule[] = [],
|
||||
sources: ProxyRuleSource[] = [],
|
||||
sourceRules = new Map<string, NormalizedProxyRule[]>(),
|
||||
): ProxyCompilationInput {
|
||||
return {
|
||||
manualRules, sources, sourceRules, profiles,
|
||||
routing: { defaultProfileId: 'direct', failMode: 'closed' },
|
||||
};
|
||||
}
|
||||
|
||||
function executePac(pacScript: string, url: string): string {
|
||||
const dnsDomainIs = (host: string, suffix: string) => host.endsWith(suffix);
|
||||
const resolver = new Function('dnsDomainIs', `${pacScript};return FindProxyForURL;`)(dnsDomainIs) as (rawUrl: string, host: string) => string;
|
||||
return resolver(url, new URL(url).hostname);
|
||||
}
|
||||
|
||||
describe('proxy compiler', () => {
|
||||
it('matches exact, subdomain, wildcard and URL patterns', () => {
|
||||
expect(proxyPatternMatches('example.test', 'https://api.example.test/path')).toBe(true);
|
||||
expect(proxyPatternMatches('*.example.test', 'https://example.test/path')).toBe(true);
|
||||
expect(proxyPatternMatches('api?.example.test', 'https://api1.example.test/')).toBe(true);
|
||||
expect(proxyPatternMatches('https://*/api/*', 'https://api.example.test/api/1')).toBe(true);
|
||||
expect(proxyPatternMatches('example.test', 'not-a-url')).toBe(false);
|
||||
it('matches all structured condition families', () => {
|
||||
expect(proxyConditionMatches({ type: 'host_exact', value: 'api.example.test' }, 'https://api.example.test/path')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'host_suffix', value: 'example.test' }, 'https://a.example.test/path')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'host_wildcard', value: 'api?.example.test' }, 'https://api1.example.test/')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'url_prefix', value: 'https://example.test/api/' }, 'https://example.test/api/1')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'url_wildcard', value: '*://*.example.test/*' }, 'https://api.example.test/1')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'keyword', value: '/login?' }, 'https://example.test/login?next=/')).toBe(true);
|
||||
expect(proxyConditionMatches({ type: 'host_exact', value: 'example.test' }, 'not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('orders PAC branches by priority and applies fail-open', () => {
|
||||
const pac = compileProxyRules(rules, profiles, { defaultProfileId: 'direct', failMode: 'open' });
|
||||
expect(pac.indexOf('High [priority=20]')).toBeLessThan(pac.indexOf('Low [priority=10]'));
|
||||
expect(pac).toContain('PROXY 127.0.0.1:8083; DIRECT');
|
||||
expect(pac.trim().endsWith('}')).toBe(true);
|
||||
it('keeps deterministic manual order in the generated PAC', () => {
|
||||
const input = compilationInput([
|
||||
manualRule('API through MITM', 0, 'host_exact', 'api.example.test'),
|
||||
manualRule('Example direct', 1, 'host_suffix', 'example.test', 'direct'),
|
||||
]);
|
||||
const artifact = compileProxyRules(input);
|
||||
expect(executePac(artifact.pacScript, 'https://api.example.test/')).toBe('PROXY 127.0.0.1:8083');
|
||||
expect(executePac(artifact.pacScript, 'https://www.example.test/')).toBe('DIRECT');
|
||||
expect(artifact.manualRuleCount).toBe(2);
|
||||
});
|
||||
|
||||
it('reports deterministic conflicts and winner', () => {
|
||||
const preview = previewProxyRules('https://api.example.test/', rules, profiles, { defaultProfileId: 'direct', failMode: 'closed' });
|
||||
expect(preview.conflict).toBe(true);
|
||||
expect(preview.matchedRuleIds).toEqual(['high', 'low']);
|
||||
expect(preview.effectiveProfileId).toBe('mitm');
|
||||
it('compiles subscription exceptions before a shared host trie', () => {
|
||||
const rules: NormalizedProxyRule[] = [
|
||||
{ sourceId: 'source', ordinal: 0, condition: { type: 'host_exact', value: 'allowed.example.test' }, exception: true, raw: '@@||allowed.example.test^' },
|
||||
{ sourceId: 'source', ordinal: 1, condition: { type: 'host_suffix', value: 'example.test' }, exception: false, raw: '||example.test^' },
|
||||
];
|
||||
const input = compilationInput([], [source({ supportedRuleCount: rules.length })], new Map([['source', rules]]));
|
||||
const artifact = compileProxyRules(input);
|
||||
expect(executePac(artifact.pacScript, 'https://allowed.example.test/')).toBe('DIRECT');
|
||||
expect(executePac(artifact.pacScript, 'https://blocked.example.test/')).toBe('PROXY 127.0.0.1:8083');
|
||||
expect(artifact.sourceRuleCount).toBe(2);
|
||||
expect((artifact.pacScript.match(/function FindProxyForURL/g) || [])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('explains the winning rule and fallback route', () => {
|
||||
const input = compilationInput([manualRule('API', 0, 'host_exact', 'api.example.test')]);
|
||||
const matched = previewProxyRules('https://api.example.test/', input);
|
||||
expect(matched.matchedKind).toBe('manual');
|
||||
expect(matched.matchedName).toBe('API');
|
||||
expect(matched.effectiveProfileId).toBe('mitm');
|
||||
const fallback = previewProxyRules('https://other.test/', input);
|
||||
expect(fallback.matchedKind).toBe('default');
|
||||
expect(fallback.effectiveProfileId).toBe('direct');
|
||||
});
|
||||
|
||||
it('rejects invalid regular expressions before changing browser proxy state', () => {
|
||||
const input = compilationInput([manualRule('Broken regex', 0, 'host_regex', '([a-z')]);
|
||||
expect(() => compileProxyRules(input)).toThrow('无效的域名正则表达式');
|
||||
});
|
||||
|
||||
it('rejects unknown SwitchyOmega result profile names', () => {
|
||||
const customRule: NormalizedProxyRule = {
|
||||
sourceId: 'source', ordinal: 0, condition: { type: 'host_suffix', value: 'example.test' },
|
||||
exception: false, raw: 'example.test +Missing', resultProfileName: 'Missing',
|
||||
};
|
||||
const input = compilationInput([], [source()], new Map([['source', [customRule]]]));
|
||||
expect(() => compileProxyRules(input)).toThrow('引用了未知或不可路由的出口');
|
||||
});
|
||||
|
||||
it('compiles 50,000 domain rules into one compact trie within the PAC budget', () => {
|
||||
const rules: NormalizedProxyRule[] = Array.from({ length: 50_000 }, (_, ordinal) => ({
|
||||
sourceId: 'source', ordinal, condition: { type: 'host_suffix', value: `domain-${ordinal}.example` },
|
||||
exception: false, raw: `||domain-${ordinal}.example^`,
|
||||
}));
|
||||
const input = compilationInput([], [source({ supportedRuleCount: rules.length })], new Map([['source', rules]]));
|
||||
const startedAt = performance.now();
|
||||
const artifact = compileProxyRules(input);
|
||||
const elapsed = performance.now() - startedAt;
|
||||
expect(artifact.sourceRuleCount).toBe(50_000);
|
||||
expect(artifact.compiledBytes).toBeLessThan(4 * 1024 * 1024);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
expect(executePac(artifact.pacScript, 'https://domain-49999.example/')).toBe('PROXY 127.0.0.1:8083');
|
||||
});
|
||||
});
|
||||
|
||||
+256
-60
@@ -1,8 +1,46 @@
|
||||
import type { ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview } from '@/types/models';
|
||||
import type {
|
||||
NormalizedProxyRule, ProxyCondition, ProxyProfile, ProxyRouteTrace, ProxyRoutingSettings,
|
||||
ProxyRule, ProxyRulePreview, ProxyRuleSource,
|
||||
} from '@/types/models';
|
||||
import { hashText } from './hash';
|
||||
|
||||
function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
|
||||
const MAX_PAC_BYTES = 4 * 1024 * 1024;
|
||||
const LARGE_PAC_BYTES = 1024 * 1024;
|
||||
|
||||
export interface ProxyCompilationInput {
|
||||
manualRules: ProxyRule[];
|
||||
sources: ProxyRuleSource[];
|
||||
sourceRules: Map<string, NormalizedProxyRule[]>;
|
||||
profiles: ProxyProfile[];
|
||||
routing: ProxyRoutingSettings;
|
||||
}
|
||||
|
||||
export interface CompiledProxyArtifact {
|
||||
revision: string;
|
||||
pacScript: string;
|
||||
compiledBytes: number;
|
||||
manualRuleCount: number;
|
||||
sourceRuleCount: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type TrieNode = { $?: number; [key: string]: TrieNode | number | undefined };
|
||||
|
||||
function json(value: unknown): string {
|
||||
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
||||
}
|
||||
|
||||
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
|
||||
return [...rules].sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function sortedProxyRuleSources(sources: ProxyRuleSource[]): ProxyRuleSource[] {
|
||||
return [...sources].sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
|
||||
if (profile.kind === 'direct') return 'DIRECT';
|
||||
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能嵌套到规则 PAC 中`);
|
||||
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能作为自动切换出口`);
|
||||
const host = profile.host || '127.0.0.1';
|
||||
const port = profile.port || 8083;
|
||||
const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}`
|
||||
@@ -11,82 +49,240 @@ function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['fai
|
||||
return failMode === 'open' ? `${proxy}; DIRECT` : proxy;
|
||||
}
|
||||
|
||||
function pacLiteral(value: string): string {
|
||||
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
||||
function wildcardRegex(pattern: string): string {
|
||||
return `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`;
|
||||
}
|
||||
|
||||
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
|
||||
return [...rules].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function pacCondition(rawPattern: string): string {
|
||||
const pattern = rawPattern.trim();
|
||||
if (!pattern) return '';
|
||||
if (pattern.includes('://') || pattern.includes('/')) return `shExpMatch(url, ${pacLiteral(pattern)})`;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const domain = pattern.slice(2);
|
||||
return `(host === ${pacLiteral(domain)} || dnsDomainIs(host, ${pacLiteral(`.${domain}`)}))`;
|
||||
function addTrieCondition(trie: TrieNode, condition: ProxyCondition): boolean {
|
||||
if (condition.type !== 'host_exact' && condition.type !== 'host_suffix') return false;
|
||||
const labels = condition.value.toLowerCase().replace(/\.$/, '').split('.').filter(Boolean).reverse();
|
||||
if (labels.length === 0) return false;
|
||||
let node = trie;
|
||||
for (const label of labels) {
|
||||
const key = `:${label}`;
|
||||
const existing = node[key];
|
||||
if (!existing || typeof existing === 'number') node[key] = {};
|
||||
node = node[key] as TrieNode;
|
||||
}
|
||||
if (pattern.includes('*') || pattern.includes('?')) return `shExpMatch(host, ${pacLiteral(pattern)})`;
|
||||
return `(host === ${pacLiteral(pattern)} || dnsDomainIs(host, ${pacLiteral(`.${pattern}`)}))`;
|
||||
node.$ = (node.$ || 0) | (condition.type === 'host_suffix' ? 1 : 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function compileProxyRules(
|
||||
rules: ProxyRule[],
|
||||
interface ConditionCompiler {
|
||||
regexps: Array<{ value: string; flags: string }>;
|
||||
}
|
||||
|
||||
function compileCondition(condition: ProxyCondition, compiler: ConditionCompiler): string {
|
||||
const value = condition.value.trim();
|
||||
if (condition.type === 'host_exact') return `host===${json(value.toLowerCase())}`;
|
||||
if (condition.type === 'host_suffix') {
|
||||
const domain = value.toLowerCase();
|
||||
return `(host===${json(domain)}||dnsDomainIs(host,${json(`.${domain}`)}))`;
|
||||
}
|
||||
if (condition.type === 'url_prefix') return `url.indexOf(${json(value)})===0`;
|
||||
if (condition.type === 'keyword') return `url.indexOf(${json(value)})>=0`;
|
||||
const expression = condition.type === 'host_wildcard' || condition.type === 'url_wildcard'
|
||||
? wildcardRegex(value)
|
||||
: value;
|
||||
try {
|
||||
new RegExp(expression, 'i');
|
||||
} catch {
|
||||
throw new Error(`无效的${condition.type.startsWith('host_') ? '域名' : 'URL'}正则表达式:${value.slice(0, 160)}`);
|
||||
}
|
||||
const index = compiler.regexps.push({ value: expression, flags: 'i' }) - 1;
|
||||
return `__rx[${index}].test(${condition.type.startsWith('host_') ? 'host' : 'url'})`;
|
||||
}
|
||||
|
||||
function resolveProfile(
|
||||
profileIdOrName: string | undefined,
|
||||
profiles: ProxyProfile[],
|
||||
routing: ProxyRoutingSettings = { defaultProfileId: 'direct', failMode: 'closed' },
|
||||
): string {
|
||||
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||
const branches = sortedProxyRules(rules)
|
||||
.filter((rule) => rule.enabled && rule.patterns.length > 0)
|
||||
.flatMap((rule) => {
|
||||
const profile = profileMap.get(rule.proxyProfileId);
|
||||
if (!profile) return [];
|
||||
const conditions = rule.patterns.map(pacCondition).filter(Boolean);
|
||||
return conditions.length > 0 ? [` // ${rule.name} [priority=${rule.priority}]\n if (${conditions.join(' || ')}) return ${pacLiteral(profileToPac(profile, routing.failMode))};`] : [];
|
||||
fallbackId: string,
|
||||
): ProxyProfile {
|
||||
const profile = profiles.find((item) => item.id === profileIdOrName || item.name === profileIdOrName)
|
||||
|| profiles.find((item) => item.id === fallbackId)
|
||||
|| profiles.find((item) => item.id === 'direct');
|
||||
if (!profile || !['direct', 'fixed_servers'].includes(profile.kind)) throw new Error(`自动切换出口不存在:${profileIdOrName || fallbackId}`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function resolveNamedSourceProfile(name: string, source: ProxyRuleSource, profiles: ProxyProfile[]): ProxyProfile {
|
||||
const profile = profiles.find((item) => item.id === name || item.name === name);
|
||||
if (!profile || !['direct', 'fixed_servers'].includes(profile.kind)) {
|
||||
throw new Error(`规则源“${source.name}”引用了未知或不可路由的出口:${name}`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function proxyCompilationRevision(input: ProxyCompilationInput): string {
|
||||
return hashText(JSON.stringify({
|
||||
manual: sortedProxyRules(input.manualRules).map((rule) => [
|
||||
rule.id, rule.enabled, rule.order, rule.condition.type, rule.condition.value, rule.proxyProfileId, rule.updatedAt,
|
||||
]),
|
||||
sources: sortedProxyRuleSources(input.sources).map((source) => [
|
||||
source.id, source.enabled, source.order, source.revision, source.matchProfileId, source.bypassProfileId,
|
||||
]),
|
||||
profiles: input.profiles.map((profile) => [
|
||||
profile.id, profile.kind, profile.scheme, profile.host, profile.port, profile.bypass, profile.authEnabled,
|
||||
]),
|
||||
routing: input.routing,
|
||||
}));
|
||||
}
|
||||
|
||||
function sourceBlock(
|
||||
source: ProxyRuleSource,
|
||||
rules: NormalizedProxyRule[],
|
||||
input: ProxyCompilationInput,
|
||||
compiler: ConditionCompiler,
|
||||
tries: TrieNode[],
|
||||
): string[] {
|
||||
if (rules.length === 0) return [];
|
||||
const hasCustomResults = rules.some((rule) => rule.resultProfileName);
|
||||
if (hasCustomResults) {
|
||||
return rules.map((rule) => {
|
||||
const target = rule.exception
|
||||
? resolveProfile(source.bypassProfileId, input.profiles, input.routing.defaultProfileId)
|
||||
: rule.resultProfileName
|
||||
? resolveNamedSourceProfile(rule.resultProfileName, source, input.profiles)
|
||||
: resolveProfile(source.matchProfileId, input.profiles, source.matchProfileId);
|
||||
return `if(${compileCondition(rule.condition, compiler)})return ${json(profileToPac(target, input.routing.failMode))};`;
|
||||
});
|
||||
const fallback = profileMap.get(routing.defaultProfileId) || profileMap.get('direct');
|
||||
return `function FindProxyForURL(url, host) {\n${branches.join('\n')}\n return ${pacLiteral(fallback ? profileToPac(fallback, routing.failMode) : 'DIRECT')};\n}`;
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
for (const exception of [true, false]) {
|
||||
const selected = rules.filter((rule) => rule.exception === exception);
|
||||
if (selected.length === 0) continue;
|
||||
const trie: TrieNode = {};
|
||||
const slow: string[] = [];
|
||||
for (const rule of selected) {
|
||||
if (!addTrieCondition(trie, rule.condition)) slow.push(compileCondition(rule.condition, compiler));
|
||||
}
|
||||
const conditions: string[] = [];
|
||||
if (Object.keys(trie).length > 0) {
|
||||
const trieIndex = tries.push(trie) - 1;
|
||||
conditions.push(`__matchHost(host,__tries[${trieIndex}])`);
|
||||
}
|
||||
conditions.push(...slow);
|
||||
if (conditions.length === 0) continue;
|
||||
const target = resolveProfile(
|
||||
exception ? source.bypassProfileId : source.matchProfileId,
|
||||
input.profiles,
|
||||
input.routing.defaultProfileId,
|
||||
);
|
||||
const result = json(profileToPac(target, input.routing.failMode));
|
||||
const chunkSize = 64;
|
||||
for (let index = 0; index < conditions.length; index += chunkSize) {
|
||||
blocks.push(`if(${conditions.slice(index, index + chunkSize).join('||')})return ${result};`);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function wildcardRegexp(pattern: string): RegExp {
|
||||
return new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`, 'i');
|
||||
export function compileProxyRules(input: ProxyCompilationInput): CompiledProxyArtifact {
|
||||
const compiler: ConditionCompiler = { regexps: [] };
|
||||
const tries: TrieNode[] = [];
|
||||
const body: string[] = [];
|
||||
const enabledManualRules = sortedProxyRules(input.manualRules).filter((rule) => rule.enabled);
|
||||
for (const rule of enabledManualRules) {
|
||||
const target = resolveProfile(rule.proxyProfileId, input.profiles, input.routing.defaultProfileId);
|
||||
body.push(`if(${compileCondition(rule.condition, compiler)})return ${json(profileToPac(target, input.routing.failMode))};`);
|
||||
}
|
||||
|
||||
let sourceRuleCount = 0;
|
||||
for (const source of sortedProxyRuleSources(input.sources).filter((item) => item.enabled && item.revision)) {
|
||||
const rules = input.sourceRules.get(source.id) || [];
|
||||
sourceRuleCount += rules.length;
|
||||
body.push(...sourceBlock(source, rules, input, compiler, tries));
|
||||
}
|
||||
|
||||
const fallback = resolveProfile(input.routing.defaultProfileId, input.profiles, 'direct');
|
||||
const regexps = `[${compiler.regexps.map((item) => `new RegExp(${json(item.value)},${json(item.flags)})`).join(',')}]`;
|
||||
const pacScript = `var __rx=${regexps},__tries=${json(tries)};function __matchHost(host,trie){var parts=host.toLowerCase().split('.'),node=trie;for(var i=parts.length-1;i>=0;i--){node=node[':'+parts[i]];if(!node)return false;if((node.$||0)&1)return true}return !!((node.$||0)&2)}function FindProxyForURL(url,host){${body.join('')}return ${json(profileToPac(fallback, input.routing.failMode))};}`;
|
||||
const compiledBytes = new TextEncoder().encode(pacScript).byteLength;
|
||||
if (compiledBytes > MAX_PAC_BYTES) {
|
||||
throw new Error(`编译后的 PAC 为 ${(compiledBytes / 1024 / 1024).toFixed(2)} MB,超过 4 MB 安全上限;请停用重叠规则源或拆分自动切换方案`);
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
if (compiledBytes > LARGE_PAC_BYTES) warnings.push(`PAC 已达到 ${(compiledBytes / 1024 / 1024).toFixed(2)} MB,请关注首次应用耗时`);
|
||||
if (compiler.regexps.length > 1_000) warnings.push(`${compiler.regexps.length} 条规则进入正则慢速路径`);
|
||||
return {
|
||||
revision: proxyCompilationRevision(input),
|
||||
pacScript,
|
||||
compiledBytes,
|
||||
manualRuleCount: enabledManualRules.length,
|
||||
sourceRuleCount,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function proxyPatternMatches(rawPattern: string, rawUrl: string): boolean {
|
||||
export function proxyConditionMatches(condition: ProxyCondition, rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const pattern = rawPattern.trim();
|
||||
if (!pattern) return false;
|
||||
if (pattern.includes('://') || pattern.includes('/')) return wildcardRegexp(pattern).test(rawUrl);
|
||||
if (pattern.startsWith('*.')) {
|
||||
const domain = pattern.slice(2).toLowerCase();
|
||||
return url.hostname.toLowerCase() === domain || url.hostname.toLowerCase().endsWith(`.${domain}`);
|
||||
const host = url.hostname.toLowerCase();
|
||||
const value = condition.value.trim();
|
||||
if (condition.type === 'host_exact') return host === value.toLowerCase();
|
||||
if (condition.type === 'host_suffix') {
|
||||
const domain = value.toLowerCase();
|
||||
return host === domain || host.endsWith(`.${domain}`);
|
||||
}
|
||||
if (pattern.includes('*') || pattern.includes('?')) return wildcardRegexp(pattern).test(url.hostname);
|
||||
return url.hostname.toLowerCase() === pattern.toLowerCase() || url.hostname.toLowerCase().endsWith(`.${pattern.toLowerCase()}`);
|
||||
if (condition.type === 'url_prefix') return rawUrl.startsWith(value);
|
||||
if (condition.type === 'keyword') return rawUrl.includes(value);
|
||||
const regexp = new RegExp(
|
||||
condition.type === 'host_wildcard' || condition.type === 'url_wildcard' ? wildcardRegex(value) : value,
|
||||
'i',
|
||||
);
|
||||
return regexp.test(condition.type.startsWith('host_') ? host : rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function previewProxyRules(
|
||||
url: string,
|
||||
rules: ProxyRule[],
|
||||
profiles: ProxyProfile[],
|
||||
routing: ProxyRoutingSettings,
|
||||
): ProxyRulePreview {
|
||||
const matches = sortedProxyRules(rules).filter((rule) => rule.enabled && rule.patterns.some((pattern) => proxyPatternMatches(pattern, url)));
|
||||
const profileIds = [...new Set(matches.map((rule) => rule.proxyProfileId))];
|
||||
const effectiveProfileId = matches[0]?.proxyProfileId || routing.defaultProfileId;
|
||||
const profile = profiles.find((item) => item.id === effectiveProfileId) || profiles.find((item) => item.id === 'direct')!;
|
||||
export function previewProxyRules(url: string, input: ProxyCompilationInput): ProxyRulePreview {
|
||||
const parsed = new URL(url);
|
||||
const trace: ProxyRouteTrace[] = [];
|
||||
for (const rule of sortedProxyRules(input.manualRules).filter((item) => item.enabled)) {
|
||||
const matched = proxyConditionMatches(rule.condition, url);
|
||||
if (trace.length < 12) trace.push({
|
||||
kind: 'manual', name: rule.name, condition: `${rule.condition.type}: ${rule.condition.value}`,
|
||||
matched, profileId: rule.proxyProfileId,
|
||||
});
|
||||
if (matched) {
|
||||
const profile = resolveProfile(rule.proxyProfileId, input.profiles, input.routing.defaultProfileId);
|
||||
return {
|
||||
url, hostname: parsed.hostname, effectiveProfileId: profile.id, effectiveProxy: profileToPac(profile, input.routing.failMode),
|
||||
matchedKind: 'manual', matchedName: rule.name, matchedCondition: rule.condition.value, matchedRuleId: rule.id, trace,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const source of sortedProxyRuleSources(input.sources).filter((item) => item.enabled && item.revision)) {
|
||||
const rules = input.sourceRules.get(source.id) || [];
|
||||
const hasCustomResults = rules.some((rule) => rule.resultProfileName);
|
||||
const previewOrder = hasCustomResults
|
||||
? rules
|
||||
: [...rules.filter((rule) => rule.exception), ...rules.filter((rule) => !rule.exception)];
|
||||
const matchedRule = previewOrder.find((rule) => proxyConditionMatches(rule.condition, url));
|
||||
trace.push({
|
||||
kind: 'source', name: source.name, condition: matchedRule?.condition.value, matched: Boolean(matchedRule),
|
||||
profileId: matchedRule?.exception ? source.bypassProfileId : source.matchProfileId,
|
||||
});
|
||||
if (matchedRule) {
|
||||
const profile = resolveProfile(
|
||||
matchedRule.exception ? source.bypassProfileId : source.matchProfileId,
|
||||
input.profiles, input.routing.defaultProfileId,
|
||||
);
|
||||
const effectiveProfile = !matchedRule.exception && matchedRule.resultProfileName
|
||||
? resolveNamedSourceProfile(matchedRule.resultProfileName, source, input.profiles)
|
||||
: profile;
|
||||
return {
|
||||
url, hostname: parsed.hostname, effectiveProfileId: effectiveProfile.id, effectiveProxy: profileToPac(effectiveProfile, input.routing.failMode),
|
||||
matchedKind: 'source', matchedName: source.name, matchedCondition: matchedRule.raw, matchedSourceId: source.id, trace,
|
||||
};
|
||||
}
|
||||
}
|
||||
const profile = resolveProfile(input.routing.defaultProfileId, input.profiles, 'direct');
|
||||
trace.push({ kind: 'default', name: '默认出口', matched: true, profileId: profile.id });
|
||||
return {
|
||||
url,
|
||||
matchedRuleIds: matches.map((rule) => rule.id),
|
||||
effectiveRuleId: matches[0]?.id,
|
||||
effectiveProfileId: profile.id,
|
||||
effectiveProxy: profileToPac(profile, routing.failMode),
|
||||
conflict: profileIds.length > 1,
|
||||
conflictProfileIds: profileIds,
|
||||
url, hostname: parsed.hostname, effectiveProfileId: profile.id, effectiveProxy: profileToPac(profile, input.routing.failMode),
|
||||
matchedKind: 'default', matchedName: '默认出口', trace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function hashText(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxyRuleSource } from './parser';
|
||||
|
||||
describe('proxy rule source parser', () => {
|
||||
it('auto-detects and decodes base64 AutoProxy lists', () => {
|
||||
const source = '[AutoProxy 0.2]\n! comment\n@@||allowed.example.test^\n||blocked.example.test^\n';
|
||||
const parsed = parseProxyRuleSource(btoa(source), 'auto', 'gfw');
|
||||
expect(parsed.diagnostics.detectedFormat).toBe('autoproxy');
|
||||
expect(parsed.diagnostics.total).toBe(2);
|
||||
expect(parsed.diagnostics.supported).toBe(2);
|
||||
expect(parsed.rules[0]).toMatchObject({ exception: true, condition: { type: 'host_suffix', value: 'allowed.example.test' } });
|
||||
expect(parsed.rules[1]).toMatchObject({ exception: false, condition: { type: 'host_suffix', value: 'blocked.example.test' } });
|
||||
});
|
||||
|
||||
it('preserves SwitchyOmega result profile names', () => {
|
||||
const parsed = parseProxyRuleSource(`
|
||||
[SwitchyOmega Conditions]
|
||||
@with result
|
||||
HostWildcard: *.internal.example +Yakit MITM
|
||||
UrlRegex: ^https://public\\.example/ +Direct
|
||||
`, 'auto', 'omega');
|
||||
expect(parsed.diagnostics.detectedFormat).toBe('switchyomega');
|
||||
expect(parsed.rules).toHaveLength(2);
|
||||
expect(parsed.rules[0]).toMatchObject({
|
||||
condition: { type: 'host_wildcard', value: '*.internal.example' }, resultProfileName: 'Yakit MITM',
|
||||
});
|
||||
expect(parsed.rules[1]).toMatchObject({ condition: { type: 'url_regex' }, resultProfileName: 'Direct' });
|
||||
});
|
||||
|
||||
it('normalizes hosts lists while reporting duplicates and invalid lines', () => {
|
||||
const parsed = parseProxyRuleSource(`
|
||||
0.0.0.0 ads.example
|
||||
ads.example
|
||||
127.0.0.1 tracker.example # comment
|
||||
10.20.30.40 api.internal.example auth.internal.example
|
||||
not a valid hosts row
|
||||
`, 'hosts', 'hosts');
|
||||
expect(parsed.rules.map((rule) => rule.condition.value)).toEqual([
|
||||
'ads.example', 'tracker.example', 'api.internal.example', 'auth.internal.example',
|
||||
]);
|
||||
expect(parsed.diagnostics.total).toBe(6);
|
||||
expect(parsed.diagnostics.ignored).toBe(1);
|
||||
expect(parsed.diagnostics.invalid).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import type {
|
||||
NormalizedProxyRule, ProxyCondition, ProxyRuleParseDiagnostics, ProxyRuleSourceFormat,
|
||||
} from '@/types/models';
|
||||
|
||||
export interface ParsedProxyRuleSource {
|
||||
rules: NormalizedProxyRule[];
|
||||
diagnostics: ProxyRuleParseDiagnostics;
|
||||
decodedText: string;
|
||||
}
|
||||
|
||||
interface ParseContext {
|
||||
sourceId: string;
|
||||
rules: NormalizedProxyRule[];
|
||||
total: number;
|
||||
ignored: number;
|
||||
invalid: number;
|
||||
warnings: string[];
|
||||
seen: Set<string>;
|
||||
}
|
||||
|
||||
const HOST_PATTERN = /^(?:\*\.)?(?:[a-z0-9_-]+\.)*[a-z0-9_-]+\.?$/i;
|
||||
|
||||
function warning(context: ParseContext, message: string): void {
|
||||
if (context.warnings.length < 20) context.warnings.push(message);
|
||||
}
|
||||
|
||||
function normalizeHost(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function validRegex(value: string): boolean {
|
||||
try {
|
||||
new RegExp(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function addRule(
|
||||
context: ParseContext,
|
||||
raw: string,
|
||||
condition: ProxyCondition | undefined,
|
||||
exception = false,
|
||||
resultProfileName?: string,
|
||||
): void {
|
||||
if (!condition || !condition.value.trim()) {
|
||||
context.invalid += 1;
|
||||
warning(context, `无法解析:${raw.slice(0, 120)}`);
|
||||
return;
|
||||
}
|
||||
const normalized: ProxyCondition = {
|
||||
type: condition.type,
|
||||
value: condition.type === 'host_exact' || condition.type === 'host_suffix'
|
||||
? normalizeHost(condition.value)
|
||||
: condition.type === 'host_wildcard'
|
||||
? condition.value.trim().toLowerCase().replace(/\.$/, '')
|
||||
: condition.value.trim(),
|
||||
};
|
||||
if (!normalized.value || (normalized.type === 'host_exact' || normalized.type === 'host_suffix') && !HOST_PATTERN.test(normalized.value)) {
|
||||
context.invalid += 1;
|
||||
warning(context, `无效的域名条件:${raw.slice(0, 120)}`);
|
||||
return;
|
||||
}
|
||||
if ((normalized.type === 'host_regex' || normalized.type === 'url_regex') && !validRegex(normalized.value)) {
|
||||
context.invalid += 1;
|
||||
warning(context, `无效的正则表达式:${raw.slice(0, 120)}`);
|
||||
return;
|
||||
}
|
||||
const key = `${exception ? '!' : ''}${normalized.type}:${normalized.value}:${resultProfileName || ''}`;
|
||||
if (context.seen.has(key)) {
|
||||
context.ignored += 1;
|
||||
return;
|
||||
}
|
||||
context.seen.add(key);
|
||||
context.rules.push({
|
||||
sourceId: context.sourceId,
|
||||
ordinal: context.rules.length,
|
||||
condition: normalized,
|
||||
exception,
|
||||
raw,
|
||||
...(resultProfileName ? { resultProfileName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function decodeAutoProxyText(input: string): string {
|
||||
const text = input.trim().replace(/^\uFEFF/, '');
|
||||
if (text.startsWith('[AutoProxy')) return text;
|
||||
const compact = text.replace(/\s+/g, '');
|
||||
if (!compact.startsWith('W0F1dG9Qcm94')) return text;
|
||||
try {
|
||||
const binary = atob(compact);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
const decoded = new TextDecoder().decode(bytes).replace(/^\uFEFF/, '');
|
||||
return decoded.startsWith('[AutoProxy') ? decoded : text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function detectFormat(text: string): Exclude<ProxyRuleSourceFormat, 'auto'> {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('[AutoProxy') || trimmed.replace(/\s+/g, '').startsWith('W0F1dG9Qcm94')) return 'autoproxy';
|
||||
if (/^\[SwitchyOmega Conditions/m.test(trimmed) || /^@(with|note)\b/im.test(trimmed)) return 'switchyomega';
|
||||
return 'hosts';
|
||||
}
|
||||
|
||||
function autoProxyCondition(value: string): ProxyCondition | undefined {
|
||||
let pattern = value.trim();
|
||||
if (!pattern) return undefined;
|
||||
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
|
||||
return { type: 'url_regex', value: pattern.slice(1, -1) };
|
||||
}
|
||||
const optionIndex = pattern.indexOf('$');
|
||||
if (optionIndex >= 0) pattern = pattern.slice(0, optionIndex);
|
||||
if (pattern.includes('##') || pattern.includes('#@#') || pattern.includes('#?#') || pattern.includes('#$#')) return undefined;
|
||||
if (pattern.startsWith('||')) {
|
||||
const host = pattern.slice(2).split(/[\^/*]/, 1)[0];
|
||||
return host ? { type: 'host_suffix', value: host } : undefined;
|
||||
}
|
||||
if (pattern.startsWith('|')) {
|
||||
pattern = pattern.slice(1).replace(/\|$/, '');
|
||||
return pattern ? { type: 'url_prefix', value: pattern } : undefined;
|
||||
}
|
||||
if (!pattern.includes('*') && !pattern.includes('^')) return { type: 'keyword', value: pattern };
|
||||
return { type: 'url_wildcard', value: pattern.replaceAll('^', '*') };
|
||||
}
|
||||
|
||||
function parseAutoProxy(text: string, context: ParseContext): void {
|
||||
const exclusive: Array<{ raw: string; condition: ProxyCondition }> = [];
|
||||
const regular: Array<{ raw: string; condition: ProxyCondition }> = [];
|
||||
for (const sourceLine of text.split(/\r?\n|\r/)) {
|
||||
let line = sourceLine.trim();
|
||||
if (!line || line.startsWith('!') || line.startsWith('[')) continue;
|
||||
context.total += 1;
|
||||
const exception = line.startsWith('@@');
|
||||
if (exception) line = line.slice(2);
|
||||
const condition = autoProxyCondition(line);
|
||||
if (!condition) {
|
||||
context.ignored += 1;
|
||||
continue;
|
||||
}
|
||||
(exception ? exclusive : regular).push({ raw: sourceLine.trim(), condition });
|
||||
}
|
||||
for (const item of exclusive) addRule(context, item.raw, item.condition, true);
|
||||
for (const item of regular) addRule(context, item.raw, item.condition, false);
|
||||
}
|
||||
|
||||
const SWITCHY_TYPES: Record<string, ProxyCondition['type']> = {
|
||||
'': 'host_wildcard', h: 'host_wildcard', w: 'host_wildcard', hw: 'host_wildcard', host: 'host_wildcard',
|
||||
wildcard: 'host_wildcard', hostwildcard: 'host_wildcard',
|
||||
r: 'host_regex', hr: 'host_regex', regex: 'host_regex', hostregex: 'host_regex',
|
||||
u: 'url_wildcard', uw: 'url_wildcard', url: 'url_wildcard', urlwildcard: 'url_wildcard',
|
||||
ur: 'url_regex', uregex: 'url_regex', urlregex: 'url_regex',
|
||||
k: 'keyword', kw: 'keyword', keyword: 'keyword',
|
||||
};
|
||||
|
||||
function switchyCondition(value: string): ProxyCondition | undefined {
|
||||
const typed = value.match(/^([A-Za-z]+):\s*(.*)$/);
|
||||
const typeKey = typed?.[1].toLowerCase() || '';
|
||||
const pattern = (typed?.[2] ?? value).trim();
|
||||
if (pattern === '*') return undefined;
|
||||
const type = SWITCHY_TYPES[typeKey];
|
||||
if (!type) return undefined;
|
||||
if (type === 'host_wildcard' && !pattern.includes('*') && !pattern.includes('?')) return { type: 'host_suffix', value: pattern };
|
||||
return { type, value: pattern };
|
||||
}
|
||||
|
||||
function parseSwitchyOmega(text: string, context: ParseContext): void {
|
||||
let withResult = false;
|
||||
for (const sourceLine of text.split(/\r?\n|\r/)) {
|
||||
let line = sourceLine.trim();
|
||||
if (!line || line.startsWith('[') || line.startsWith(';') || line.startsWith('#')) continue;
|
||||
if (line.startsWith('@')) {
|
||||
if (/^@with\s+results?$/i.test(line)) withResult = true;
|
||||
continue;
|
||||
}
|
||||
context.total += 1;
|
||||
let exception = false;
|
||||
if (line.startsWith('!')) {
|
||||
exception = true;
|
||||
line = line.slice(1).trim();
|
||||
}
|
||||
let resultProfileName: string | undefined;
|
||||
if (withResult) {
|
||||
const resultIndex = line.lastIndexOf(' +');
|
||||
if (resultIndex >= 0) {
|
||||
resultProfileName = line.slice(resultIndex + 2).trim();
|
||||
line = line.slice(0, resultIndex).trim();
|
||||
}
|
||||
if (line === '*') {
|
||||
context.ignored += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const condition = switchyCondition(line);
|
||||
if (!condition) {
|
||||
context.invalid += 1;
|
||||
warning(context, `不支持的 SwitchyOmega 条件:${sourceLine.slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
addRule(context, sourceLine.trim(), condition, exception, resultProfileName);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHosts(text: string, context: ParseContext): void {
|
||||
for (const sourceLine of text.split(/\r?\n|\r/)) {
|
||||
const line = sourceLine.replace(/\s*[#!;].*$/, '').trim();
|
||||
if (!line) continue;
|
||||
const fields = line.split(/\s+/);
|
||||
const ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(fields[0])
|
||||
&& fields[0].split('.').every((part) => Number(part) <= 255);
|
||||
const ipv6 = fields[0].includes(':') && /^[0-9a-f:]+$/i.test(fields[0]);
|
||||
const candidates = fields.length === 1 ? fields : ipv4 || ipv6 ? fields.slice(1) : [];
|
||||
context.total += Math.max(1, candidates.length);
|
||||
if (candidates.length === 0) {
|
||||
context.invalid += 1;
|
||||
warning(context, `无效的域名行:${sourceLine.slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!HOST_PATTERN.test(candidate)) {
|
||||
context.invalid += 1;
|
||||
warning(context, `无效的域名行:${sourceLine.slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
addRule(context, sourceLine.trim(), { type: 'host_suffix', value: candidate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProxyRuleSource(
|
||||
input: string,
|
||||
requestedFormat: ProxyRuleSourceFormat,
|
||||
sourceId: string,
|
||||
): ParsedProxyRuleSource {
|
||||
const detectedFormat = requestedFormat === 'auto' ? detectFormat(input) : requestedFormat;
|
||||
const decodedText = detectedFormat === 'autoproxy' ? decodeAutoProxyText(input) : input.replace(/^\uFEFF/, '');
|
||||
const context: ParseContext = {
|
||||
sourceId,
|
||||
rules: [],
|
||||
total: 0,
|
||||
ignored: 0,
|
||||
invalid: 0,
|
||||
warnings: [],
|
||||
seen: new Set(),
|
||||
};
|
||||
if (detectedFormat === 'autoproxy') parseAutoProxy(decodedText, context);
|
||||
else if (detectedFormat === 'switchyomega') parseSwitchyOmega(decodedText, context);
|
||||
else parseHosts(decodedText, context);
|
||||
context.rules.forEach((rule, ordinal) => { rule.ordinal = ordinal; });
|
||||
return {
|
||||
rules: context.rules,
|
||||
decodedText,
|
||||
diagnostics: {
|
||||
detectedFormat,
|
||||
total: context.total,
|
||||
supported: context.rules.length,
|
||||
ignored: context.ignored,
|
||||
invalid: context.invalid,
|
||||
warnings: context.warnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { NormalizedProxyRule, ProxyRulePage } from '@/types/models';
|
||||
|
||||
const DATABASE_NAME = 'yakit-proxy-rules';
|
||||
const DATABASE_VERSION = 1;
|
||||
const CHUNK_SIZE = 512;
|
||||
const SOURCE_STORE = 'source-revisions';
|
||||
const RULE_STORE = 'rule-chunks';
|
||||
const ARTIFACT_STORE = 'compiled-artifacts';
|
||||
const MAX_COMPILED_ARTIFACTS = 8;
|
||||
|
||||
interface SourceRevisionRecord {
|
||||
key: string;
|
||||
sourceId: string;
|
||||
revision: string;
|
||||
content: string;
|
||||
ruleCount: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface RuleChunkRecord {
|
||||
key: string;
|
||||
sourceId: string;
|
||||
revision: string;
|
||||
index: number;
|
||||
rules: NormalizedProxyRule[];
|
||||
}
|
||||
|
||||
export interface CompiledProxyArtifactRecord {
|
||||
revision: string;
|
||||
pacScript: string;
|
||||
compiledBytes: number;
|
||||
manualRuleCount: number;
|
||||
sourceRuleCount: number;
|
||||
warnings: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function revisionKey(sourceId: string, revision: string): string {
|
||||
return `${sourceId}:${revision}`;
|
||||
}
|
||||
|
||||
function chunkKey(sourceId: string, revision: string, index: number): string {
|
||||
return `${revisionKey(sourceId, revision)}:${String(index).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB 请求失败'));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error || new Error('IndexedDB 事务已中止'));
|
||||
transaction.onerror = () => reject(transaction.error || new Error('IndexedDB 事务失败'));
|
||||
});
|
||||
}
|
||||
|
||||
let databasePromise: Promise<IDBDatabase> | undefined;
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
if (!globalThis.indexedDB) return Promise.reject(new Error('当前浏览器不支持 IndexedDB 规则仓库'));
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = globalThis.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(SOURCE_STORE)) {
|
||||
const sources = database.createObjectStore(SOURCE_STORE, { keyPath: 'key' });
|
||||
sources.createIndex('sourceId', 'sourceId', { unique: false });
|
||||
}
|
||||
if (!database.objectStoreNames.contains(RULE_STORE)) {
|
||||
const chunks = database.createObjectStore(RULE_STORE, { keyPath: 'key' });
|
||||
chunks.createIndex('sourceId', 'sourceId', { unique: false });
|
||||
chunks.createIndex('sourceRevision', ['sourceId', 'revision'], { unique: false });
|
||||
}
|
||||
if (!database.objectStoreNames.contains(ARTIFACT_STORE)) {
|
||||
database.createObjectStore(ARTIFACT_STORE, { keyPath: 'revision' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
database.onversionchange = () => {
|
||||
database.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
resolve(database);
|
||||
};
|
||||
request.onerror = () => {
|
||||
databasePromise = undefined;
|
||||
reject(request.error || new Error('无法打开 IndexedDB 规则仓库'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
databasePromise = undefined;
|
||||
reject(new Error('规则仓库升级被其他扩展页面阻塞,请关闭 Options 后重试'));
|
||||
};
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
|
||||
export async function putSourceRevision(
|
||||
sourceId: string,
|
||||
revision: string,
|
||||
content: string,
|
||||
rules: NormalizedProxyRule[],
|
||||
): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction([SOURCE_STORE, RULE_STORE], 'readwrite');
|
||||
const sourceStore = transaction.objectStore(SOURCE_STORE);
|
||||
const ruleStore = transaction.objectStore(RULE_STORE);
|
||||
const sourceRecord: SourceRevisionRecord = {
|
||||
key: revisionKey(sourceId, revision), sourceId, revision, content, ruleCount: rules.length, createdAt: Date.now(),
|
||||
};
|
||||
sourceStore.put(sourceRecord);
|
||||
for (let index = 0; index * CHUNK_SIZE < rules.length; index += 1) {
|
||||
const record: RuleChunkRecord = {
|
||||
key: chunkKey(sourceId, revision, index), sourceId, revision, index,
|
||||
rules: rules.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE),
|
||||
};
|
||||
ruleStore.put(record);
|
||||
}
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function getSourceContent(sourceId: string, revision?: string): Promise<string | undefined> {
|
||||
if (!revision) return undefined;
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(SOURCE_STORE, 'readonly');
|
||||
const record = await requestResult(transaction.objectStore(SOURCE_STORE).get(revisionKey(sourceId, revision))) as SourceRevisionRecord | undefined;
|
||||
await transactionDone(transaction);
|
||||
return record?.content;
|
||||
}
|
||||
|
||||
export async function getSourceRules(sourceId: string, revision?: string): Promise<NormalizedProxyRule[]> {
|
||||
if (!revision) return [];
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(RULE_STORE, 'readonly');
|
||||
const records = await requestResult(
|
||||
transaction.objectStore(RULE_STORE).index('sourceRevision').getAll(IDBKeyRange.only([sourceId, revision])),
|
||||
) as RuleChunkRecord[];
|
||||
await transactionDone(transaction);
|
||||
return records.sort((left, right) => left.index - right.index).flatMap((record) => record.rules);
|
||||
}
|
||||
|
||||
async function filteredRulePage(
|
||||
sourceId: string,
|
||||
revision: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
query: string,
|
||||
): Promise<ProxyRulePage> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(RULE_STORE, 'readonly');
|
||||
const needle = query.trim().toLowerCase();
|
||||
let total = 0;
|
||||
const rules: NormalizedProxyRule[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = transaction.objectStore(RULE_STORE).index('sourceRevision').openCursor(IDBKeyRange.only([sourceId, revision]));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const chunk = cursor.value as RuleChunkRecord;
|
||||
for (const rule of chunk.rules) {
|
||||
if (!`${rule.condition.type} ${rule.condition.value} ${rule.raw}`.toLowerCase().includes(needle)) continue;
|
||||
if (total >= offset && rules.length < limit) rules.push(rule);
|
||||
total += 1;
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('无法搜索规则源'));
|
||||
});
|
||||
await transactionDone(transaction);
|
||||
return { sourceId, revision, offset, limit, total, rules };
|
||||
}
|
||||
|
||||
export async function getSourceRulePage(
|
||||
sourceId: string,
|
||||
revision: string | undefined,
|
||||
offset: number,
|
||||
limit: number,
|
||||
query = '',
|
||||
): Promise<ProxyRulePage> {
|
||||
if (!revision) return { sourceId, offset, limit, total: 0, rules: [] };
|
||||
if (query.trim()) return filteredRulePage(sourceId, revision, offset, limit, query);
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction([SOURCE_STORE, RULE_STORE], 'readonly');
|
||||
const sourceRequest = transaction.objectStore(SOURCE_STORE).get(revisionKey(sourceId, revision));
|
||||
const startChunk = Math.floor(offset / CHUNK_SIZE);
|
||||
const endChunk = Math.floor(Math.max(offset, offset + limit - 1) / CHUNK_SIZE);
|
||||
const chunkRequests: Array<Promise<RuleChunkRecord | undefined>> = [];
|
||||
for (let index = startChunk; index <= endChunk; index += 1) {
|
||||
chunkRequests.push(requestResult(transaction.objectStore(RULE_STORE).get(chunkKey(sourceId, revision, index))) as Promise<RuleChunkRecord | undefined>);
|
||||
}
|
||||
const [source, chunks] = await Promise.all([
|
||||
requestResult(sourceRequest) as Promise<SourceRevisionRecord | undefined>,
|
||||
Promise.all(chunkRequests),
|
||||
]);
|
||||
await transactionDone(transaction);
|
||||
const chunkOffset = offset - startChunk * CHUNK_SIZE;
|
||||
const rules = chunks.flatMap((chunk) => chunk?.rules || []).slice(chunkOffset, chunkOffset + limit);
|
||||
return { sourceId, revision, offset, limit, total: source?.ruleCount || 0, rules };
|
||||
}
|
||||
|
||||
function deleteBySourceId(store: IDBObjectStore, sourceId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.index('sourceId').openKeyCursor(IDBKeyRange.only(sourceId));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
store.delete(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('无法清理规则源数据'));
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSource(sourceId: string): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction([SOURCE_STORE, RULE_STORE], 'readwrite');
|
||||
await Promise.all([
|
||||
deleteBySourceId(transaction.objectStore(SOURCE_STORE), sourceId),
|
||||
deleteBySourceId(transaction.objectStore(RULE_STORE), sourceId),
|
||||
]);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function pruneSourceRevisions(sourceId: string, keepRevision: string): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction([SOURCE_STORE, RULE_STORE], 'readwrite');
|
||||
const pruneStore = (store: IDBObjectStore) => new Promise<void>((resolve, reject) => {
|
||||
const request = store.index('sourceId').openCursor(IDBKeyRange.only(sourceId));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const value = cursor.value as SourceRevisionRecord | RuleChunkRecord;
|
||||
if (value.revision !== keepRevision) cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('无法清理旧规则 revision'));
|
||||
});
|
||||
await Promise.all([pruneStore(transaction.objectStore(SOURCE_STORE)), pruneStore(transaction.objectStore(RULE_STORE))]);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function putCompiledArtifact(artifact: CompiledProxyArtifactRecord): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(ARTIFACT_STORE, 'readwrite');
|
||||
transaction.objectStore(ARTIFACT_STORE).put(artifact);
|
||||
await transactionDone(transaction);
|
||||
void pruneCompiledArtifacts().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function pruneCompiledArtifacts(): Promise<void> {
|
||||
const database = await openDatabase();
|
||||
const readTransaction = database.transaction(ARTIFACT_STORE, 'readonly');
|
||||
const artifacts = await requestResult(readTransaction.objectStore(ARTIFACT_STORE).getAll()) as CompiledProxyArtifactRecord[];
|
||||
await transactionDone(readTransaction);
|
||||
if (artifacts.length <= MAX_COMPILED_ARTIFACTS) return;
|
||||
const stale = artifacts.sort((left, right) => right.createdAt - left.createdAt).slice(MAX_COMPILED_ARTIFACTS);
|
||||
const writeTransaction = database.transaction(ARTIFACT_STORE, 'readwrite');
|
||||
const store = writeTransaction.objectStore(ARTIFACT_STORE);
|
||||
for (const artifact of stale) store.delete(artifact.revision);
|
||||
await transactionDone(writeTransaction);
|
||||
}
|
||||
|
||||
export async function getCompiledArtifact(revision: string): Promise<CompiledProxyArtifactRecord | undefined> {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(ARTIFACT_STORE, 'readonly');
|
||||
const artifact = await requestResult(transaction.objectStore(ARTIFACT_STORE).get(revision)) as CompiledProxyArtifactRecord | undefined;
|
||||
await transactionDone(transaction);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export { CHUNK_SIZE as PROXY_RULE_CHUNK_SIZE };
|
||||
+450
-86
@@ -1,19 +1,42 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY, PROXY_STATS_STORAGE_KEY } from '@/protocol/storage';
|
||||
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type {
|
||||
ExtensionState, ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview, ProxyRuleStats,
|
||||
ExtensionState, ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePage, ProxyRulePreview,
|
||||
ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput,
|
||||
} from '@/types/models';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import {
|
||||
compileProxyRules, previewProxyRules, proxyPatternMatches, sortedProxyRules,
|
||||
compileProxyRules, previewProxyRules, profileToPac, proxyCompilationRevision,
|
||||
type CompiledProxyArtifact, type ProxyCompilationInput,
|
||||
} from './compiler';
|
||||
import { hashText } from './hash';
|
||||
import { parseProxyRuleSource } from './parser';
|
||||
import {
|
||||
deleteSource, getCompiledArtifact, getSourceContent, getSourceRulePage, getSourceRules,
|
||||
pruneSourceRevisions, putCompiledArtifact, putSourceRevision,
|
||||
} from './repository';
|
||||
|
||||
export { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
|
||||
const SOURCE_REFRESH_ALARM = 'proxy-rule-sources-refresh';
|
||||
const MAX_SOURCE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_CONFIGURATION_CONTENT_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
interface StorageArea {
|
||||
get(key: string): Promise<Record<string, unknown>>;
|
||||
set(items: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
|
||||
const authPasswords = new Map<string, string>();
|
||||
const sourceRefreshes = new Map<string, Promise<ExtensionState>>();
|
||||
let proxyState: ExtensionState | undefined;
|
||||
|
||||
function isFirefox(): boolean {
|
||||
return Boolean(import.meta.env.FIREFOX);
|
||||
}
|
||||
|
||||
function isRoutable(profile: ProxyProfile): boolean {
|
||||
return profile.kind === 'direct' || profile.kind === 'fixed_servers';
|
||||
}
|
||||
|
||||
function chromeProxyValue(profile: ProxyProfile): object {
|
||||
if (profile.kind === 'direct') return { mode: 'direct' };
|
||||
@@ -49,106 +72,390 @@ function firefoxProxyValue(profile: ProxyProfile): object {
|
||||
}
|
||||
if (profile.scheme === 'socks4' || profile.scheme === 'socks5') {
|
||||
return {
|
||||
proxyType: 'manual',
|
||||
socks: `${profile.host}:${profile.port}`,
|
||||
socksVersion: profile.scheme === 'socks4' ? 4 : 5,
|
||||
proxyDNS: true,
|
||||
passthrough: profile.bypass.join(', '),
|
||||
proxyType: 'manual', socks: `${profile.host}:${profile.port}`, socksVersion: profile.scheme === 'socks4' ? 4 : 5,
|
||||
proxyDNS: true, passthrough: profile.bypass.join(', '),
|
||||
};
|
||||
}
|
||||
const address = `${profile.scheme || 'http'}://${profile.host}:${profile.port}`;
|
||||
return { proxyType: 'manual', http: address, ssl: address, httpProxyAll: true, passthrough: profile.bypass.join(', ') };
|
||||
}
|
||||
|
||||
export async function switchProxy(profileId: string): Promise<void> {
|
||||
const state = await getState();
|
||||
const profile = state.proxyProfiles.find((item) => item.id === profileId);
|
||||
if (!profile) throw new Error('代理配置不存在');
|
||||
async function setPacScript(pacScript: string): Promise<void> {
|
||||
if (!browser.proxy?.settings) throw new Error('当前浏览器不支持代理 API');
|
||||
|
||||
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
|
||||
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
|
||||
await updateState((current) => ({ ...current, activeProxyId: profileId }));
|
||||
}
|
||||
|
||||
export async function applyProxyRules(): Promise<void> {
|
||||
const state = await getState();
|
||||
const pacScript = compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting);
|
||||
if (isFirefox()) {
|
||||
await browser.proxy.settings.set({
|
||||
value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig,
|
||||
scope: 'regular',
|
||||
});
|
||||
} else {
|
||||
await browser.proxy.settings.set({
|
||||
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
|
||||
scope: 'regular',
|
||||
return;
|
||||
}
|
||||
await browser.proxy.settings.set({
|
||||
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
|
||||
scope: 'regular',
|
||||
});
|
||||
}
|
||||
|
||||
async function setBrowserProxyProfile(profile: ProxyProfile): Promise<void> {
|
||||
if (!browser.proxy?.settings) throw new Error('当前浏览器不支持代理 API');
|
||||
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
|
||||
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
|
||||
}
|
||||
|
||||
async function compilationInput(state: ExtensionState, withRules = true): Promise<ProxyCompilationInput> {
|
||||
const sourceRules = new Map<string, Awaited<ReturnType<typeof getSourceRules>>>();
|
||||
if (withRules) {
|
||||
await Promise.all(state.proxyRuleSources.filter((source) => source.enabled && source.revision).map(async (source) => {
|
||||
sourceRules.set(source.id, await getSourceRules(source.id, source.revision));
|
||||
}));
|
||||
}
|
||||
return {
|
||||
manualRules: state.proxyRules,
|
||||
sources: state.proxyRuleSources,
|
||||
sourceRules,
|
||||
profiles: state.proxyProfiles,
|
||||
routing: state.proxyRouting,
|
||||
};
|
||||
}
|
||||
|
||||
async function compiledArtifact(state: ExtensionState): Promise<CompiledProxyArtifact> {
|
||||
const shallowInput = await compilationInput(state, false);
|
||||
const revision = proxyCompilationRevision(shallowInput);
|
||||
const cached = await getCompiledArtifact(revision);
|
||||
if (cached) return cached;
|
||||
const artifact = compileProxyRules(await compilationInput(state, true));
|
||||
await putCompiledArtifact({ ...artifact, createdAt: Date.now() });
|
||||
return artifact;
|
||||
}
|
||||
|
||||
async function applyState(state: ExtensionState): Promise<CompiledProxyArtifact> {
|
||||
const artifact = await compiledArtifact(state);
|
||||
await setPacScript(artifact.pacScript);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function withAppliedRuntime(state: ExtensionState, artifact: CompiledProxyArtifact): ExtensionState {
|
||||
return {
|
||||
...state,
|
||||
activeProxyId: 'auto',
|
||||
proxyRuntime: {
|
||||
dirty: false,
|
||||
compiledBytes: artifact.compiledBytes,
|
||||
manualRuleCount: artifact.manualRuleCount,
|
||||
sourceRuleCount: artifact.sourceRuleCount,
|
||||
appliedAt: Date.now(),
|
||||
revision: artifact.revision,
|
||||
warnings: artifact.warnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function dirtyProxyState(state: ExtensionState): ExtensionState {
|
||||
return { ...state, proxyRuntime: { ...state.proxyRuntime, dirty: true, error: undefined } };
|
||||
}
|
||||
|
||||
export async function switchProxy(profileId: string): Promise<void> {
|
||||
const state = await getState();
|
||||
const profile = state.proxyProfiles.find((item) => item.id === profileId);
|
||||
if (!profile) throw new Error('代理配置不存在');
|
||||
await setBrowserProxyProfile(profile);
|
||||
await updateState((current) => ({ ...current, activeProxyId: profileId }));
|
||||
}
|
||||
|
||||
export async function saveProxyProfile(profile: ProxyProfile): Promise<ExtensionState> {
|
||||
return updateState(async (current) => {
|
||||
const next = dirtyProxyState({
|
||||
...current,
|
||||
proxyProfiles: [...current.proxyProfiles.filter((item) => item.id !== profile.id), profile],
|
||||
});
|
||||
}
|
||||
await updateState((current) => ({ ...current, activeProxyId: 'rules' }));
|
||||
if (current.activeProxyId === profile.id) await setBrowserProxyProfile(profile);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
interface StorageArea {
|
||||
get(key: string): Promise<Record<string, unknown>>;
|
||||
set(items: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
|
||||
const authPasswords = new Map<string, string>();
|
||||
const ruleStats = new Map<string, ProxyRuleStats>();
|
||||
let routingState: ExtensionState | undefined;
|
||||
let statsTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
|
||||
void getState().then((state) => { routingState = state; }).catch(() => undefined);
|
||||
if (sessionStorage) {
|
||||
void sessionStorage.get(PROXY_AUTH_STORAGE_KEY).then((stored) => {
|
||||
const values = stored[PROXY_AUTH_STORAGE_KEY];
|
||||
if (values && typeof values === 'object') for (const [id, password] of Object.entries(values)) if (typeof password === 'string') authPasswords.set(id, password);
|
||||
}).catch(() => undefined);
|
||||
void sessionStorage.get(PROXY_STATS_STORAGE_KEY).then((stored) => {
|
||||
const values = stored[PROXY_STATS_STORAGE_KEY];
|
||||
if (Array.isArray(values)) for (const item of values) {
|
||||
const stat = item as ProxyRuleStats;
|
||||
if (typeof stat.ruleId === 'string' && Number.isFinite(stat.hits)) ruleStats.set(stat.ruleId, stat);
|
||||
export async function applyProxyRules(): Promise<ExtensionState> {
|
||||
let failure: unknown;
|
||||
const result = await updateState(async (current) => {
|
||||
try {
|
||||
return withAppliedRuntime(current, await applyState(current));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
return {
|
||||
...current,
|
||||
proxyRuntime: { ...current.proxyRuntime, dirty: true, error: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
if (failure) throw failure;
|
||||
return result;
|
||||
}
|
||||
|
||||
browser.storage.onChanged.addListener((changes) => {
|
||||
if (isStateStorageChange(changes)) void getState().then((state) => { routingState = state; }).catch(() => undefined);
|
||||
});
|
||||
|
||||
function persistStats(): void {
|
||||
if (!sessionStorage || statsTimer) return;
|
||||
statsTimer = globalThis.setTimeout(() => {
|
||||
statsTimer = undefined;
|
||||
void sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [...ruleStats.values()] }).catch(() => undefined);
|
||||
}, 1_000);
|
||||
export async function compileCurrentProxyRules(): Promise<CompiledProxyArtifact> {
|
||||
return compiledArtifact(await getState());
|
||||
}
|
||||
|
||||
browser.webRequest.onBeforeRequest.addListener((details) => {
|
||||
const state = routingState;
|
||||
if (!state || state.activeProxyId !== 'rules') return;
|
||||
const rule = sortedProxyRules(state.proxyRules).find((item) => item.enabled && item.patterns.some((pattern) => proxyPatternMatches(pattern, details.url)));
|
||||
if (!rule) return;
|
||||
const current = ruleStats.get(rule.id) || { ruleId: rule.id, hits: 0 };
|
||||
ruleStats.set(rule.id, { ...current, hits: current.hits + 1, lastHitAt: Date.now(), lastUrl: details.url.slice(0, 2_048) });
|
||||
persistStats();
|
||||
}, { urls: ['<all_urls>'] });
|
||||
export async function previewCurrentProxyRules(url: string): Promise<ProxyRulePreview> {
|
||||
return previewProxyRules(url, await compilationInput(await getState(), true));
|
||||
}
|
||||
|
||||
browser.webRequest.onAuthRequired.addListener((details, asyncCallback) => {
|
||||
const state = routingState;
|
||||
const profile = state?.proxyProfiles.find((item) => item.id === state.activeProxyId);
|
||||
const password = profile && authPasswords.get(profile.id);
|
||||
const response = details.isProxy && profile?.authEnabled && profile.authUsername && password
|
||||
? { authCredentials: { username: profile.authUsername, password } }
|
||||
: {};
|
||||
if (asyncCallback) {
|
||||
asyncCallback(response);
|
||||
return undefined;
|
||||
function resolvedSourceUrl(value: string): string {
|
||||
const url = new URL(value);
|
||||
if (url.hostname === 'github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const blobIndex = parts.indexOf('blob');
|
||||
if (blobIndex === 2 && parts.length > 4) {
|
||||
return `https://raw.githubusercontent.com/${parts[0]}/${parts[1]}/${parts.slice(blobIndex + 1).join('/')}`;
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function assertSourceProfiles(input: Pick<ProxyRuleSourceInput, 'matchProfileId' | 'bypassProfileId'>, state: ExtensionState): void {
|
||||
for (const profileId of [input.matchProfileId, input.bypassProfileId]) {
|
||||
const profile = state.proxyProfiles.find((item) => item.id === profileId);
|
||||
if (!profile || !isRoutable(profile)) throw new Error('规则源出口必须是直接连接或固定代理');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveProxyRuleSource(input: ProxyRuleSourceInput): Promise<ProxyRuleSource> {
|
||||
const state = await getState();
|
||||
assertSourceProfiles(input, state);
|
||||
const existing = input.id ? state.proxyRuleSources.find((source) => source.id === input.id) : undefined;
|
||||
const normalizedUrl = new URL(input.url).toString();
|
||||
const identityChanged = Boolean(existing && (existing.url !== normalizedUrl || existing.format !== input.format));
|
||||
const source: ProxyRuleSource = {
|
||||
id: existing?.id || crypto.randomUUID(),
|
||||
name: input.name.trim(),
|
||||
url: normalizedUrl,
|
||||
format: input.format,
|
||||
enabled: input.enabled,
|
||||
matchProfileId: input.matchProfileId,
|
||||
bypassProfileId: input.bypassProfileId,
|
||||
order: input.order ?? existing?.order ?? state.proxyRuleSources.length,
|
||||
updateIntervalMinutes: input.updateIntervalMinutes,
|
||||
status: identityChanged ? 'idle' : existing?.status || 'idle',
|
||||
totalRuleCount: identityChanged ? 0 : existing?.totalRuleCount || 0,
|
||||
supportedRuleCount: identityChanged ? 0 : existing?.supportedRuleCount || 0,
|
||||
ignoredRuleCount: identityChanged ? 0 : existing?.ignoredRuleCount || 0,
|
||||
invalidRuleCount: identityChanged ? 0 : existing?.invalidRuleCount || 0,
|
||||
...(!identityChanged && existing ? {
|
||||
revision: existing.revision,
|
||||
contentHash: existing.contentHash,
|
||||
etag: existing.etag,
|
||||
lastModified: existing.lastModified,
|
||||
lastCheckedAt: existing.lastCheckedAt,
|
||||
lastUpdatedAt: existing.lastUpdatedAt,
|
||||
error: existing.error,
|
||||
} : {}),
|
||||
};
|
||||
await updateState((current) => dirtyProxyState({
|
||||
...current,
|
||||
proxyRuleSources: [...current.proxyRuleSources.filter((item) => item.id !== source.id), source],
|
||||
}));
|
||||
return source;
|
||||
}
|
||||
|
||||
async function fetchSource(source: ProxyRuleSource): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
if (source.etag) headers.set('If-None-Match', source.etag);
|
||||
if (source.lastModified) headers.set('If-Modified-Since', source.lastModified);
|
||||
const response = await fetch(resolvedSourceUrl(source.url), { headers, cache: 'no-cache' });
|
||||
if (response.status === 304) return response;
|
||||
if (!response.ok) throw new Error(`规则源返回 HTTP ${response.status}`);
|
||||
const length = Number(response.headers.get('content-length') || 0);
|
||||
if (length > MAX_SOURCE_BYTES) throw new Error('规则源超过 10 MB 安全上限');
|
||||
return response;
|
||||
}, { urls: ['<all_urls>'] }, [isFirefox() ? 'blocking' : 'asyncBlocking']);
|
||||
}
|
||||
|
||||
async function refreshSourceOperation(sourceId: string, applyActive: boolean): Promise<ExtensionState> {
|
||||
const before = await getState();
|
||||
const source = before.proxyRuleSources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new Error('规则源不存在');
|
||||
await updateState((current) => ({
|
||||
...current,
|
||||
proxyRuleSources: current.proxyRuleSources.map((item) => item.id === sourceId ? { ...item, status: 'updating', error: undefined } : item),
|
||||
}));
|
||||
try {
|
||||
const response = await fetchSource(source);
|
||||
if (response.status === 304) {
|
||||
return updateState((current) => ({
|
||||
...current,
|
||||
proxyRuleSources: current.proxyRuleSources.map((item) => item.id === sourceId
|
||||
? { ...item, status: item.revision ? 'ready' : 'idle', lastCheckedAt: Date.now(), error: undefined }
|
||||
: item),
|
||||
}));
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > MAX_SOURCE_BYTES) throw new Error('规则源超过 10 MB 安全上限');
|
||||
const parsed = parseProxyRuleSource(text, source.format, source.id);
|
||||
if (parsed.rules.length === 0) throw new Error('规则源没有可用的代理规则');
|
||||
const revision = `${hashText(parsed.decodedText)}-${parsed.rules.length}`;
|
||||
await putSourceRevision(source.id, revision, parsed.decodedText, parsed.rules);
|
||||
const updatedSource: ProxyRuleSource = {
|
||||
...source,
|
||||
revision,
|
||||
contentHash: hashText(parsed.decodedText),
|
||||
etag: response.headers.get('etag') || undefined,
|
||||
lastModified: response.headers.get('last-modified') || undefined,
|
||||
lastCheckedAt: Date.now(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
status: 'ready',
|
||||
totalRuleCount: parsed.diagnostics.total,
|
||||
supportedRuleCount: parsed.diagnostics.supported,
|
||||
ignoredRuleCount: parsed.diagnostics.ignored,
|
||||
invalidRuleCount: parsed.diagnostics.invalid,
|
||||
error: parsed.diagnostics.warnings.length > 0 ? parsed.diagnostics.warnings.join('\n') : undefined,
|
||||
};
|
||||
const saved = await updateState(async (current) => {
|
||||
const liveSource = current.proxyRuleSources.find((item) => item.id === sourceId);
|
||||
if (!liveSource || liveSource.url !== source.url || liveSource.format !== source.format) {
|
||||
throw new Error('规则源在下载期间已被修改,本次结果已丢弃');
|
||||
}
|
||||
const staged = dirtyProxyState({
|
||||
...current,
|
||||
proxyRuleSources: current.proxyRuleSources.map((item) => item.id === sourceId ? updatedSource : item),
|
||||
});
|
||||
return applyActive && current.activeProxyId === 'auto'
|
||||
? withAppliedRuntime(staged, await applyState(staged))
|
||||
: staged;
|
||||
});
|
||||
void pruneSourceRevisions(source.id, revision).catch(() => undefined);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
await updateState((current) => ({
|
||||
...current,
|
||||
proxyRuleSources: current.proxyRuleSources.map((item) => item.id === sourceId
|
||||
&& item.url === source.url && item.format === source.format ? {
|
||||
...item,
|
||||
status: 'error',
|
||||
lastCheckedAt: Date.now(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} : item),
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshProxyRuleSource(sourceId: string, applyActive = true): Promise<ExtensionState> {
|
||||
const existing = sourceRefreshes.get(sourceId);
|
||||
if (existing) return existing;
|
||||
const refresh = refreshSourceOperation(sourceId, applyActive).finally(() => sourceRefreshes.delete(sourceId));
|
||||
sourceRefreshes.set(sourceId, refresh);
|
||||
return refresh;
|
||||
}
|
||||
|
||||
export async function removeProxyRuleSource(sourceId: string): Promise<ExtensionState> {
|
||||
const saved = await updateState(async (current) => {
|
||||
if (!current.proxyRuleSources.some((source) => source.id === sourceId)) return current;
|
||||
const staged = dirtyProxyState({
|
||||
...current,
|
||||
proxyRuleSources: current.proxyRuleSources.filter((source) => source.id !== sourceId),
|
||||
});
|
||||
return current.activeProxyId === 'auto'
|
||||
? withAppliedRuntime(staged, await applyState(staged))
|
||||
: staged;
|
||||
});
|
||||
void deleteSource(sourceId).catch(() => undefined);
|
||||
return saved;
|
||||
}
|
||||
|
||||
export async function getProxyRuleSourcePage(
|
||||
sourceId: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
query?: string,
|
||||
): Promise<ProxyRulePage> {
|
||||
const source = (await getState()).proxyRuleSources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new Error('规则源不存在');
|
||||
return getSourceRulePage(source.id, source.revision, offset, limit, query);
|
||||
}
|
||||
|
||||
export async function routeCurrentSite(url: string, proxyProfileId: string): Promise<ExtensionState> {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return updateState(async (state) => {
|
||||
const profile = state.proxyProfiles.find((item) => item.id === proxyProfileId);
|
||||
if (!profile || !isRoutable(profile)) throw new Error('当前出口不能用于自动切换规则');
|
||||
const now = Date.now();
|
||||
const existing = state.proxyRules.find((rule) => rule.condition.type === 'host_exact'
|
||||
&& rule.condition.value.toLowerCase() === hostname);
|
||||
const rule: ProxyRule = {
|
||||
id: existing?.id || crypto.randomUUID(),
|
||||
name: `${hostname} 路由`,
|
||||
enabled: true,
|
||||
condition: { type: 'host_exact', value: hostname },
|
||||
proxyProfileId,
|
||||
order: existing?.order ?? -1,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const staged = dirtyProxyState({
|
||||
...state,
|
||||
proxyRules: [rule, ...state.proxyRules.filter((item) => item.id !== rule.id)].map((item, order) => ({ ...item, order })),
|
||||
});
|
||||
return withAppliedRuntime(staged, await applyState(staged));
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearCurrentSiteRoute(url: string): Promise<ExtensionState> {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return updateState(async (state) => {
|
||||
const proxyRules = state.proxyRules
|
||||
.filter((rule) => !(rule.condition.type === 'host_exact' && rule.condition.value.toLowerCase() === hostname))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((rule, order) => ({ ...rule, order }));
|
||||
const staged = dirtyProxyState({ ...state, proxyRules });
|
||||
return withAppliedRuntime(staged, await applyState(staged));
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportProxyConfiguration(): Promise<ProxyConfiguration> {
|
||||
const state = await getState();
|
||||
const sources: ProxyRuleSourceExport[] = [];
|
||||
let contentBytes = 0;
|
||||
for (const source of state.proxyRuleSources) {
|
||||
const content = await getSourceContent(source.id, source.revision);
|
||||
contentBytes += content ? new TextEncoder().encode(content).byteLength : 0;
|
||||
if (contentBytes > MAX_CONFIGURATION_CONTENT_BYTES) {
|
||||
throw new Error('规则源内容合计超过 25 MB,请减少订阅后再导出完整配置');
|
||||
}
|
||||
sources.push({ source, content });
|
||||
}
|
||||
return { version: 2, profiles: state.proxyProfiles, rules: state.proxyRules, sources, routing: state.proxyRouting };
|
||||
}
|
||||
|
||||
export async function importProxyConfiguration(configuration: ProxyConfiguration): Promise<ExtensionState> {
|
||||
const contentBytes = configuration.sources.reduce(
|
||||
(total, item) => total + (item.content ? new TextEncoder().encode(item.content).byteLength : 0), 0,
|
||||
);
|
||||
if (contentBytes > MAX_CONFIGURATION_CONTENT_BYTES) throw new Error('导入配置中的规则源内容合计不能超过 25 MB');
|
||||
const profileIds = new Set(configuration.profiles.map((profile) => profile.id));
|
||||
if (profileIds.size !== configuration.profiles.length || !profileIds.has(configuration.routing.defaultProfileId)) {
|
||||
throw new Error('代理配置包含重复或缺失的出口 ID');
|
||||
}
|
||||
const routableIds = new Set(configuration.profiles.filter(isRoutable).map((profile) => profile.id));
|
||||
if (configuration.rules.some((rule) => !routableIds.has(rule.proxyProfileId))) throw new Error('手动规则引用了不可用的出口');
|
||||
if (configuration.sources.some(({ source }) => !routableIds.has(source.matchProfileId) || !routableIds.has(source.bypassProfileId))) {
|
||||
throw new Error('规则源引用了不可用的出口');
|
||||
}
|
||||
await Promise.all(configuration.sources.map(async ({ source, content }) => {
|
||||
if (!content || !source.revision) return;
|
||||
const parsed = parseProxyRuleSource(content, source.format, source.id);
|
||||
await putSourceRevision(source.id, source.revision, parsed.decodedText, parsed.rules);
|
||||
}));
|
||||
return updateState(async (current) => {
|
||||
const direct = configuration.profiles.find((profile) => profile.id === 'direct' && profile.kind === 'direct')
|
||||
|| { id: 'direct', name: '直接连接', kind: 'direct' as const, bypass: [], builtin: true };
|
||||
await setBrowserProxyProfile(direct);
|
||||
return dirtyProxyState({
|
||||
...current,
|
||||
proxyProfiles: configuration.profiles,
|
||||
proxyRules: configuration.rules,
|
||||
proxyRuleSources: configuration.sources.map(({ source }) => source),
|
||||
proxyRouting: configuration.routing,
|
||||
activeProxyId: 'direct',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function setProxyAuthPassword(profileId: string, password: string): Promise<void> {
|
||||
if (password) authPasswords.set(profileId, password);
|
||||
@@ -160,11 +467,68 @@ export function hasProxyAuthPassword(profileId: string): boolean {
|
||||
return authPasswords.has(profileId);
|
||||
}
|
||||
|
||||
export function getProxyRuleStats(): ProxyRuleStats[] {
|
||||
return [...ruleStats.values()].sort((left, right) => right.hits - left.hits);
|
||||
if (sessionStorage) {
|
||||
void sessionStorage.get(PROXY_AUTH_STORAGE_KEY).then((stored) => {
|
||||
const values = stored[PROXY_AUTH_STORAGE_KEY];
|
||||
if (values && typeof values === 'object') {
|
||||
for (const [id, password] of Object.entries(values)) if (typeof password === 'string') authPasswords.set(id, password);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function clearProxyRuleStats(): Promise<void> {
|
||||
ruleStats.clear();
|
||||
if (sessionStorage) await sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [] });
|
||||
void getState().then((state) => { proxyState = state; }).catch(() => undefined);
|
||||
browser.storage.onChanged.addListener((changes) => {
|
||||
if (isStateStorageChange(changes)) void getState().then((state) => { proxyState = state; }).catch(() => undefined);
|
||||
});
|
||||
|
||||
browser.webRequest.onAuthRequired.addListener((details, asyncCallback) => {
|
||||
const resolveCredentials = (state?: ExtensionState) => {
|
||||
const challenger = details.challenger;
|
||||
const profile = state?.proxyProfiles.find((item) => item.kind === 'fixed_servers'
|
||||
&& item.host === challenger?.host && item.port === challenger?.port);
|
||||
const password = profile && authPasswords.get(profile.id);
|
||||
return details.isProxy && profile?.authEnabled && profile.authUsername && password
|
||||
? { authCredentials: { username: profile.authUsername, password } }
|
||||
: {};
|
||||
};
|
||||
if (!proxyState && asyncCallback) {
|
||||
void getState().then((state) => {
|
||||
proxyState = state;
|
||||
asyncCallback(resolveCredentials(state));
|
||||
}).catch(() => asyncCallback({}));
|
||||
return undefined;
|
||||
}
|
||||
const response = resolveCredentials(proxyState);
|
||||
if (asyncCallback) {
|
||||
asyncCallback(response);
|
||||
return undefined;
|
||||
}
|
||||
return response;
|
||||
}, { urls: ['<all_urls>'] }, [isFirefox() ? 'blocking' : 'asyncBlocking']);
|
||||
|
||||
async function refreshDueSources(): Promise<void> {
|
||||
const state = await getState();
|
||||
const now = Date.now();
|
||||
const due = state.proxyRuleSources.filter((source) => source.enabled
|
||||
&& (!source.lastCheckedAt || now - source.lastCheckedAt >= source.updateIntervalMinutes * 60_000));
|
||||
let changed = false;
|
||||
for (const source of due) {
|
||||
try {
|
||||
const refreshed = await refreshProxyRuleSource(source.id, false);
|
||||
const nextSource = refreshed.proxyRuleSources.find((item) => item.id === source.id);
|
||||
if (nextSource?.revision !== source.revision) changed = true;
|
||||
} catch {
|
||||
// The source retains its last good revision and exposes the update error in state.
|
||||
}
|
||||
}
|
||||
if (changed && (await getState()).activeProxyId === 'auto') await applyProxyRules();
|
||||
}
|
||||
|
||||
if (browser.alarms) {
|
||||
void browser.alarms.create(SOURCE_REFRESH_ALARM, { periodInMinutes: 30 });
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === SOURCE_REFRESH_ALARM) void refreshDueSources();
|
||||
});
|
||||
}
|
||||
|
||||
export { profileToPac } from './compiler';
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, CircleDot, Gauge, Plus, Route, Save, Search, Trash2, Zap,
|
||||
} from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models';
|
||||
import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
const ROW_HEIGHT = 58;
|
||||
const LIST_HEIGHT = 408;
|
||||
const OVERSCAN = 4;
|
||||
|
||||
function hostFromUrl(url?: string): string {
|
||||
try { return url ? new URL(url).hostname : ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function freshRule(count: number, url?: string): ProxyRule {
|
||||
const now = Date.now();
|
||||
const hostname = hostFromUrl(url);
|
||||
return {
|
||||
id: uuidv7(),
|
||||
name: hostname ? `${hostname} 路由` : '新路由规则',
|
||||
enabled: true,
|
||||
condition: { type: hostname ? 'host_exact' : 'host_suffix', value: hostname },
|
||||
proxyProfileId: 'yakit-mitm',
|
||||
order: count,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function conditionHint(type: ProxyConditionType): string {
|
||||
if (type === 'host_exact') return 'api.example.com';
|
||||
if (type === 'host_suffix') return 'example.com';
|
||||
if (type === 'host_wildcard') return '*.example.com';
|
||||
if (type === 'host_regex') return '(^|\\.)example\\.(com|net)$';
|
||||
if (type === 'url_prefix') return 'https://example.com/api/';
|
||||
if (type === 'url_wildcard') return '*://*.example.com/*';
|
||||
if (type === 'url_regex') return '^https://example\\.com/';
|
||||
return 'login';
|
||||
}
|
||||
|
||||
export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewProps) {
|
||||
const rules = useMemo(() => [...state.proxyRules].sort((left, right) => left.order - right.order), [state.proxyRules]);
|
||||
const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]);
|
||||
const [draft, setDraft] = useState<ProxyRule>(() => freshRule(state.proxyRules.length, tab?.url));
|
||||
const [previewUrl, setPreviewUrl] = useState(tab?.url || 'https://example.com/');
|
||||
const [preview, setPreview] = useState<ProxyRulePreview>();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab?.url?.startsWith('http')) setPreviewUrl(tab.url);
|
||||
}, [tab?.url]);
|
||||
|
||||
const selectedExists = rules.some((rule) => rule.id === draft.id);
|
||||
const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const visibleCount = Math.ceil(LIST_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2;
|
||||
const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount);
|
||||
const enabledSources = state.proxyRuleSources.filter((source) => source.enabled && source.revision);
|
||||
const active = state.activeProxyId === 'auto';
|
||||
|
||||
const save = () => run(async () => {
|
||||
const now = Date.now();
|
||||
const next = { ...draft, updatedAt: now, createdAt: draft.createdAt || now, order: selectedExists ? draft.order : rules.length };
|
||||
const updated = await request('proxy.rule.save', next);
|
||||
setState(updated);
|
||||
setDraft(updated.proxyRules.find((rule) => rule.id === next.id) || next);
|
||||
}, '规则已保存,等待应用');
|
||||
|
||||
const reorder = (rule: ProxyRule, delta: -1 | 1) => run(async () => {
|
||||
const index = rules.findIndex((item) => item.id === rule.id);
|
||||
const target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= rules.length) return;
|
||||
const ids = rules.map((item) => item.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
setState(await request('proxy.rules.reorder', { ids }));
|
||||
});
|
||||
|
||||
const explain = () => run(async () => setPreview(await request('proxy.rules.preview', { url: previewUrl })));
|
||||
const quickRoute = (profileId: string) => run(async () => {
|
||||
if (!tab?.url) return;
|
||||
setState(await request('proxy.site.route', { url: tab.url, profileId }));
|
||||
setPreview(await request('proxy.rules.preview', { url: tab.url }));
|
||||
}, '当前站点规则已创建并应用');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>自动切换</h1><p>手动规则优先,随后按顺序匹配订阅源,未命中时使用默认出口。</p></div>
|
||||
<Button variant="primary" disabled={busy || (!state.proxyRuntime.dirty && active)} onClick={() => void run(async () => setState(await request('proxy.auto.apply')), '自动切换已应用')}><Zap size={16} />{active && !state.proxyRuntime.dirty ? '已应用' : '应用自动切换'}</Button>
|
||||
</div>
|
||||
|
||||
<section className={`proxy-apply-band ${active ? 'is-active' : ''} ${state.proxyRuntime.dirty ? 'is-dirty' : ''}`}>
|
||||
<div className="proxy-mode-state"><span><i />{active ? '自动切换运行中' : '自动切换未启用'}</span><strong>{state.proxyRuntime.dirty ? '存在未应用的更改' : state.proxyRuntime.appliedAt ? '配置与浏览器一致' : '尚未生成 PAC'}</strong></div>
|
||||
<Field label="默认出口"><select value={state.proxyRouting.defaultProfileId} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, defaultProfileId: event.target.value })), '默认出口已更新')}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="代理失败"><select value={state.proxyRouting.failMode} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, failMode: event.target.value as 'open' | 'closed' })), '失败策略已更新')}><option value="closed">保持失败</option><option value="open">回退到 DIRECT</option></select></Field>
|
||||
<div className="proxy-compile-metrics"><span><strong>{state.proxyRuntime.manualRuleCount}</strong> 手动</span><span><strong>{state.proxyRuntime.sourceRuleCount}</strong> 订阅</span><span><strong>{formatBytes(state.proxyRuntime.compiledBytes)}</strong> PAC</span></div>
|
||||
</section>
|
||||
|
||||
{state.proxyRuntime.error && <div className="proxy-runtime-alert"><AlertTriangle size={16} /><span><strong>上一轮应用失败</strong>{state.proxyRuntime.error}</span></div>}
|
||||
|
||||
<section className="proxy-route-probe">
|
||||
<div className="proxy-probe-input"><Search size={15} /><input value={previewUrl} onChange={(event) => setPreviewUrl(event.target.value)} placeholder="输入 URL 检查路由" /><Button size="sm" onClick={() => void explain()}>解释路由</Button></div>
|
||||
{preview ? <div className="proxy-probe-result"><span>{preview.matchedKind === 'default' ? '默认出口' : preview.matchedKind === 'manual' ? '手动规则' : '规则订阅'}</span><strong>{preview.matchedName}</strong><i>→</i><b>{state.proxyProfiles.find((profile) => profile.id === preview.effectiveProfileId)?.name}</b><small title={preview.matchedCondition}>{preview.matchedCondition || preview.hostname}</small></div> : <div className="proxy-probe-placeholder">查看某个请求为什么使用当前出口</div>}
|
||||
</section>
|
||||
|
||||
{tab?.url?.startsWith('http') && <section className="proxy-current-site">
|
||||
<div><CircleDot size={16} /><span><strong>{hostFromUrl(tab.url)}</strong><small>为当前站点创建最高优先级规则</small></span></div>
|
||||
<div><Button size="sm" disabled={busy} onClick={() => void quickRoute('direct')}>始终直连</Button>{state.proxyProfiles.some((profile) => profile.id === 'yakit-mitm') && <Button size="sm" variant="primary" disabled={busy} onClick={() => void quickRoute('yakit-mitm')}>始终走 MITM</Button>}</div>
|
||||
</section>}
|
||||
|
||||
<div className="proxy-rule-workspace">
|
||||
<section className="proxy-rule-table">
|
||||
<div className="proxy-table-toolbar"><div><span>手动规则</span><strong>{rules.length}</strong></div><Button size="sm" onClick={() => setDraft(freshRule(rules.length, tab?.url))}><Plus size={14} />新建</Button></div>
|
||||
<div className="proxy-rule-head"><span>顺序</span><span>规则</span><span>条件</span><span>出口</span><span>状态</span><span /></div>
|
||||
{rules.length === 0 ? <div className="proxy-table-empty"><Route size={22} /><strong>没有手动规则</strong><span>可以从当前站点快速创建,或在右侧添加。</span></div> : <div
|
||||
className="proxy-virtual-list"
|
||||
ref={listRef}
|
||||
style={{ height: LIST_HEIGHT }}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
><div style={{ height: rules.length * ROW_HEIGHT, position: 'relative' }}>{visibleRules.map((rule, visibleIndex) => {
|
||||
const index = firstVisible + visibleIndex;
|
||||
const profile = state.proxyProfiles.find((item) => item.id === rule.proxyProfileId);
|
||||
return <div
|
||||
key={rule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`proxy-rule-row ${draft.id === rule.id ? 'is-selected' : ''}`}
|
||||
style={{ position: 'absolute', top: index * ROW_HEIGHT, height: ROW_HEIGHT }}
|
||||
onClick={() => setDraft(rule)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setDraft(rule);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="proxy-rule-order"><Button size="icon" variant="ghost" aria-label="上移规则" disabled={index === 0} onClick={(event) => { event.stopPropagation(); void reorder(rule, -1); }}><ArrowUp size={13} /></Button><Button size="icon" variant="ghost" aria-label="下移规则" disabled={index === rules.length - 1} onClick={(event) => { event.stopPropagation(); void reorder(rule, 1); }}><ArrowDown size={13} /></Button></span>
|
||||
<span><strong>{rule.name}</strong><small>{CONDITION_LABELS[rule.condition.type]}</small></span>
|
||||
<code title={rule.condition.value}>{rule.condition.value}</code>
|
||||
<span>{profile?.name || '出口已删除'}</span>
|
||||
<i className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '启用' : '停用'}</i>
|
||||
<Route size={14} />
|
||||
</div>;
|
||||
})}</div></div>}
|
||||
{enabledSources.length > 0 && <div className="proxy-source-summary"><Gauge size={15} /><span><strong>{enabledSources.length} 个订阅源参与匹配</strong><small>{enabledSources.reduce((sum, source) => sum + source.supportedRuleCount, 0).toLocaleString()} 条已规范化规则</small></span></div>}
|
||||
</section>
|
||||
|
||||
<aside className="proxy-rule-inspector">
|
||||
<div className="proxy-editor-heading"><div><span>{selectedExists ? '编辑规则' : '新建规则'}</span><h2>{draft.name || '未命名规则'}</h2></div><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} /></div>
|
||||
<Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="条件类型"><select value={draft.condition.type} onChange={(event) => setDraft({ ...draft, condition: { type: event.target.value as ProxyConditionType, value: '' } })}>{Object.entries(CONDITION_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
<Field label="匹配值" hint={draft.condition.type.startsWith('url_') ? 'Chrome 对 HTTPS PAC 会隐藏路径与查询参数,优先使用域名条件。' : undefined}><textarea rows={4} value={draft.condition.value} placeholder={conditionHint(draft.condition.type)} onChange={(event) => setDraft({ ...draft, condition: { ...draft.condition, value: event.target.value } })} /></Field>
|
||||
<Field label="代理出口"><select value={draft.proxyProfileId} onChange={(event) => setDraft({ ...draft, proxyProfileId: event.target.value })}>{routableProfiles.map((profile) => <option value={profile.id} key={profile.id}>{profile.name} · {proxyProfileDetail(profile)}</option>)}</select></Field>
|
||||
<div className="proxy-inspector-actions"><Button variant="primary" disabled={busy || !draft.name.trim() || !draft.condition.value.trim()} onClick={() => void save()}><Save size={15} />保存规则</Button>{selectedExists && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { const updated = await request('proxy.rule.delete', { id: draft.id }); setState(updated); setDraft(freshRule(updated.proxyRules.length, tab?.url)); }, '规则已删除')}><Trash2 size={15} />删除</Button>}</div>
|
||||
{preview && <section className="proxy-trace"><div><CheckCircle2 size={15} /><strong>匹配顺序</strong></div>{preview.trace.slice(0, 8).map((item, index) => <p key={`${item.kind}:${item.name}:${index}`} className={item.matched ? 'is-match' : ''}><i>{item.matched ? <CheckCircle2 size={13} /> : <span />}</i><span><strong>{item.name}</strong><small>{item.condition || item.kind}</small></span></p>)}</section>}
|
||||
</aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronRight, KeyRound, Network, Plus, Power, Save, Trash2 } from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ProxyProfile } from '@/types/models';
|
||||
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
function createProfile(): ProxyProfile {
|
||||
return {
|
||||
id: uuidv7(), name: '新代理出口', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8080, bypass: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) {
|
||||
const [draft, setDraft] = useState<ProxyProfile>(() => state.proxyProfiles[0] || createProfile());
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfigured, setPasswordConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPassword('');
|
||||
void request('proxy.auth.status', { profileId: draft.id }).then((result) => setPasswordConfigured(result.configured));
|
||||
}, [draft.id]);
|
||||
|
||||
const save = () => run(async () => {
|
||||
setState(await request('proxy.save', draft));
|
||||
if (draft.authEnabled) {
|
||||
if (password) await request('proxy.auth.set', { profileId: draft.id, password });
|
||||
} else {
|
||||
await request('proxy.auth.set', { profileId: draft.id, password: '' });
|
||||
}
|
||||
setPassword('');
|
||||
setPasswordConfigured(Boolean(draft.authEnabled && (password || passwordConfigured)));
|
||||
}, '代理出口已保存');
|
||||
|
||||
const remove = () => run(async () => {
|
||||
setState(await request('proxy.delete', { id: draft.id }));
|
||||
await request('proxy.auth.set', { profileId: draft.id, password: '' });
|
||||
setDraft(state.proxyProfiles[0] || createProfile());
|
||||
}, '代理出口已删除');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>代理出口</h1><p>维护浏览器可以使用的直连、HTTP、HTTPS、SOCKS 和 PAC 出口。</p></div>
|
||||
<Button variant="primary" onClick={() => setDraft(createProfile())}><Plus size={16} />新建出口</Button>
|
||||
</div>
|
||||
|
||||
<div className="proxy-profile-workspace">
|
||||
<section className="proxy-profile-index" aria-label="代理出口列表">
|
||||
<div className="proxy-panel-label"><span>出口</span><strong>{state.proxyProfiles.length}</strong></div>
|
||||
<div className="proxy-profile-list">
|
||||
{state.proxyProfiles.map((profile) => <button
|
||||
key={profile.id}
|
||||
className={`${draft.id === profile.id ? 'is-selected' : ''} ${state.activeProxyId === profile.id ? 'is-active' : ''}`}
|
||||
onClick={() => setDraft({ ...profile, bypass: [...profile.bypass] })}
|
||||
>
|
||||
<span className="proxy-profile-icon"><Network size={16} /></span>
|
||||
<span><strong>{profile.name}</strong><small>{proxyProfileDetail(profile)}</small></span>
|
||||
{state.activeProxyId === profile.id && <i>使用中</i>}
|
||||
<ChevronRight size={15} />
|
||||
</button>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="proxy-profile-editor">
|
||||
<div className="proxy-editor-heading">
|
||||
<div><span>{draft.builtin ? '内置出口' : '自定义出口'}</span><h2>{draft.name}</h2></div>
|
||||
<span className={`proxy-live-state ${state.activeProxyId === draft.id ? 'is-live' : ''}`}><i />{state.activeProxyId === draft.id ? '当前生效' : '未使用'}</span>
|
||||
</div>
|
||||
<div className="proxy-form-grid">
|
||||
<Field label="名称"><input value={draft.name} disabled={draft.id === 'direct' || draft.id === 'system'} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="类型"><select value={draft.kind} disabled={draft.builtin} onChange={(event) => setDraft({ ...draft, kind: event.target.value as ProxyProfile['kind'] })}>{Object.entries(PROXY_KIND_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
{draft.kind === 'fixed_servers' && <>
|
||||
<Field label="协议"><select value={draft.scheme || 'http'} onChange={(event) => setDraft({ ...draft, scheme: event.target.value as ProxyProfile['scheme'] })}><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks4">SOCKS4</option><option value="socks5">SOCKS5</option></select></Field>
|
||||
<Field label="主机"><input value={draft.host || ''} onChange={(event) => setDraft({ ...draft, host: event.target.value })} /></Field>
|
||||
<Field label="端口"><input type="number" min="1" max="65535" value={draft.port || ''} onChange={(event) => setDraft({ ...draft, port: Number(event.target.value) })} /></Field>
|
||||
<Field label="绕过列表" hint="每行一个域名、IP 或 <local>"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field>
|
||||
</>}
|
||||
{draft.kind === 'pac_script' && <>
|
||||
<Field label="PAC URL"><input value={draft.pacUrl || ''} onChange={(event) => setDraft({ ...draft, pacUrl: event.target.value, pacScript: '' })} placeholder="https://example.com/proxy.pac" /></Field>
|
||||
<Field label="内联 PAC"><textarea rows={10} value={draft.pacScript || ''} onChange={(event) => setDraft({ ...draft, pacScript: event.target.value, pacUrl: '' })} /></Field>
|
||||
</>}
|
||||
</div>
|
||||
{draft.kind === 'fixed_servers' && <section className="proxy-auth-section">
|
||||
<label><span><KeyRound size={16} /><span><strong>代理认证</strong><small>{passwordConfigured ? '已保存本次浏览器会话的凭据' : '凭据仅保存在浏览器 session'}</small></span></span><Switch checked={Boolean(draft.authEnabled)} onCheckedChange={(checked) => setDraft({ ...draft, authEnabled: checked })} /></label>
|
||||
{draft.authEnabled && <div className="proxy-auth-fields"><Field label="用户名"><input value={draft.authUsername || ''} onChange={(event) => setDraft({ ...draft, authUsername: event.target.value })} /></Field><Field label="密码"><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder={passwordConfigured ? '留空以保留当前密码' : '输入密码'} /></Field></div>}
|
||||
</section>}
|
||||
<div className="proxy-editor-actions">
|
||||
<Button variant="primary" disabled={busy || !draft.name || (draft.kind === 'fixed_servers' && (!draft.host || !draft.port))} onClick={() => void save()}><Save size={16} />保存</Button>
|
||||
<Button disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: draft.id })), `${draft.name} 已启用`)}><Power size={16} />立即使用</Button>
|
||||
{!draft.builtin && <Button variant="danger" disabled={busy} onClick={() => void remove()}><Trash2 size={16} />删除</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Download, FileText, Plus, RefreshCw,
|
||||
Search, Trash2, Upload,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ProxyConfiguration, ProxyRulePage, ProxyRuleSource, ProxyRuleSourceFormat, ProxyRuleSourceInput,
|
||||
} from '@/types/models';
|
||||
import { CONDITION_LABELS, relativeTime, SOURCE_FORMAT_LABELS } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function sourceDraft(state: ProxyViewProps['state'], source?: ProxyRuleSource): ProxyRuleSourceInput {
|
||||
return source ? {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
format: source.format,
|
||||
enabled: source.enabled,
|
||||
matchProfileId: source.matchProfileId,
|
||||
bypassProfileId: source.bypassProfileId,
|
||||
order: source.order,
|
||||
updateIntervalMinutes: source.updateIntervalMinutes,
|
||||
} : {
|
||||
name: 'GitHub 规则订阅',
|
||||
url: '',
|
||||
format: 'auto',
|
||||
enabled: true,
|
||||
matchProfileId: state.proxyProfiles.some((profile) => profile.id === 'yakit-mitm') ? 'yakit-mitm' : 'direct',
|
||||
bypassProfileId: 'direct',
|
||||
order: state.proxyRuleSources.length,
|
||||
updateIntervalMinutes: 720,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceStatusLabel(source: ProxyRuleSource): string {
|
||||
if (source.status === 'updating') return '正在更新';
|
||||
if (source.status === 'error') return source.revision ? '使用上一版本' : '更新失败';
|
||||
if (source.status === 'ready') return '可用';
|
||||
return '尚未下载';
|
||||
}
|
||||
|
||||
export function RuleSourcesView({ state, setState, run, busy }: ProxyViewProps) {
|
||||
const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]);
|
||||
const orderedSources = useMemo(() => [...state.proxyRuleSources].sort((left, right) => left.order - right.order), [state.proxyRuleSources]);
|
||||
const [selectedId, setSelectedId] = useState(orderedSources[0]?.id || '');
|
||||
const selected = state.proxyRuleSources.find((source) => source.id === selectedId);
|
||||
const [draft, setDraft] = useState<ProxyRuleSourceInput>(() => sourceDraft(state, selected));
|
||||
const [page, setPage] = useState<ProxyRulePage>();
|
||||
const [query, setQuery] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(sourceDraft(state, selected));
|
||||
setQuery('');
|
||||
setOffset(0);
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected?.revision) {
|
||||
setPage(undefined);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void request('proxy.source.rules', { id: selected.id, offset, limit: PAGE_SIZE, query: query || undefined })
|
||||
.then((next) => { if (!cancelled) setPage(next); })
|
||||
.catch(() => { if (!cancelled) setPage(undefined); });
|
||||
}, 180);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [selected?.id, selected?.revision, offset, query]);
|
||||
|
||||
const reorderSource = (index: number, delta: -1 | 1) => run(async () => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= orderedSources.length) return;
|
||||
const ids = orderedSources.map((source) => source.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
setState(await request('proxy.sources.reorder', { ids }));
|
||||
}, '订阅匹配顺序已更新');
|
||||
|
||||
const saveAndRefresh = () => run(async () => {
|
||||
const saved = await request('proxy.source.save', draft);
|
||||
setSelectedId(saved.id);
|
||||
try {
|
||||
setState(await request('proxy.source.refresh', { id: saved.id }));
|
||||
} catch (error) {
|
||||
setState(await request('state.get'));
|
||||
throw error;
|
||||
}
|
||||
}, '规则源已更新');
|
||||
|
||||
const downloadConfiguration = () => run(async () => {
|
||||
const configuration = await request('proxy.config.export');
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(configuration, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = `yakit-proxy-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}, '代理配置已导出');
|
||||
|
||||
const importConfiguration = (file?: File) => run(async () => {
|
||||
if (!file) return;
|
||||
const configuration = JSON.parse(await file.text()) as ProxyConfiguration;
|
||||
setState(await request('proxy.config.import', { configuration }));
|
||||
setSelectedId('');
|
||||
}, '代理配置已导入');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>规则订阅</h1><p>从 GitHub 或任意 HTTP(S) 地址更新规则;下载失败时继续使用上一份可用版本。</p></div>
|
||||
<div className="proxy-heading-actions">
|
||||
<input ref={importRef} type="file" accept="application/json,.json" hidden onChange={(event) => { void importConfiguration(event.target.files?.[0]); event.currentTarget.value = ''; }} />
|
||||
<Button onClick={() => importRef.current?.click()}><Upload size={15} />导入</Button>
|
||||
<Button onClick={() => void downloadConfiguration()}><Download size={15} />导出</Button>
|
||||
<Button variant="primary" onClick={() => { setSelectedId(''); setDraft(sourceDraft(state)); }}><Plus size={15} />添加订阅</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proxy-source-workspace">
|
||||
<section className="proxy-source-index">
|
||||
<div className="proxy-panel-label"><span>订阅源</span><strong>{orderedSources.length}</strong></div>
|
||||
{orderedSources.length === 0 ? <div className="proxy-source-empty"><CloudDownload size={24} /><strong>尚无规则订阅</strong><span>添加 GitHub raw、AutoProxy 或域名列表。</span></div> : <div className="proxy-source-list">{orderedSources.map((source, index) => <div key={source.id} className={`proxy-source-item ${selectedId === source.id ? 'is-selected' : ''}`}>
|
||||
<button className="proxy-source-select" onClick={() => setSelectedId(source.id)}>
|
||||
<span className={`proxy-source-status ${source.status}`}><i /></span>
|
||||
<span><strong>{source.name}</strong><small>{source.supportedRuleCount.toLocaleString()} 条 · {relativeTime(source.lastUpdatedAt)}</small></span>
|
||||
<i>{sourceStatusLabel(source)}</i>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<span className="proxy-source-order"><Button size="icon" variant="ghost" aria-label="上移规则源" disabled={index === 0 || busy} onClick={() => void reorderSource(index, -1)}><ArrowUp size={13} /></Button><Button size="icon" variant="ghost" aria-label="下移规则源" disabled={index === orderedSources.length - 1 || busy} onClick={() => void reorderSource(index, 1)}><ArrowDown size={13} /></Button></span>
|
||||
</div>)}</div>}
|
||||
</section>
|
||||
|
||||
<section className="proxy-source-main">
|
||||
<div className="proxy-source-editor">
|
||||
<div className="proxy-editor-heading"><div><span>{selected ? '订阅设置' : '新规则订阅'}</span><h2>{draft.name || '未命名订阅'}</h2></div><label className="proxy-inline-switch"><span>{draft.enabled ? '参与匹配' : '已停用'}</span><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} /></label></div>
|
||||
<div className="proxy-source-form">
|
||||
<Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="订阅地址" hint="GitHub blob 地址会自动转换为 raw 地址"><input value={draft.url} placeholder="https://github.com/user/repo/blob/main/rules.txt" onChange={(event) => setDraft({ ...draft, url: event.target.value })} /></Field>
|
||||
<Field label="格式"><select value={draft.format} onChange={(event) => setDraft({ ...draft, format: event.target.value as ProxyRuleSourceFormat })}>{Object.entries(SOURCE_FORMAT_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
<Field label="匹配出口"><select value={draft.matchProfileId} onChange={(event) => setDraft({ ...draft, matchProfileId: event.target.value })}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="排除出口"><select value={draft.bypassProfileId} onChange={(event) => setDraft({ ...draft, bypassProfileId: event.target.value })}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="更新周期"><select value={draft.updateIntervalMinutes} onChange={(event) => setDraft({ ...draft, updateIntervalMinutes: Number(event.target.value) })}><option value="60">每小时</option><option value="360">每 6 小时</option><option value="720">每 12 小时</option><option value="1440">每天</option><option value="10080">每周</option></select></Field>
|
||||
</div>
|
||||
{selected?.error && <div className={`proxy-source-message ${selected.status === 'error' ? 'is-error' : 'is-warning'}`}><AlertTriangle size={15} /><span>{selected.error}</span></div>}
|
||||
<div className="proxy-editor-actions">
|
||||
<Button variant="primary" disabled={busy || !draft.name.trim() || !draft.url.trim()} onClick={() => void saveAndRefresh()}>{selected?.status === 'updating' ? <RefreshCw className="spin" size={15} /> : <CloudDownload size={15} />}保存并更新</Button>
|
||||
{selected && <Button disabled={busy} onClick={() => void run(async () => setState(await request('proxy.source.refresh', { id: selected.id })), '规则源已更新')}><RefreshCw size={15} />立即更新</Button>}
|
||||
{selected && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { const next = await request('proxy.source.delete', { id: selected.id }); setState(next); const nextId = next.proxyRuleSources[0]?.id || ''; setSelectedId(nextId); setDraft(sourceDraft(next, next.proxyRuleSources.find((source) => source.id === nextId))); }, '规则源已删除')}><Trash2 size={15} />删除</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && <section className="proxy-source-rules">
|
||||
<div className="proxy-source-rules-heading">
|
||||
<div><span>规范化规则</span><strong>{(page?.total ?? selected.supportedRuleCount).toLocaleString()}</strong></div>
|
||||
<label><Search size={14} /><input value={query} placeholder="搜索当前规则源" onChange={(event) => { setQuery(event.target.value); setOffset(0); }} /></label>
|
||||
</div>
|
||||
<div className="proxy-source-stats"><span><CheckCircle2 size={13} />{selected.supportedRuleCount.toLocaleString()} 有效</span><span>{selected.ignoredRuleCount.toLocaleString()} 忽略</span><span className={selected.invalidRuleCount ? 'is-warning' : ''}>{selected.invalidRuleCount.toLocaleString()} 无效</span><span>{SOURCE_FORMAT_LABELS[selected.format]}</span></div>
|
||||
<div className="proxy-source-rule-head"><span>#</span><span>类型</span><span>条件</span><span>结果</span></div>
|
||||
{!page ? <div className="proxy-source-rule-loading"><RefreshCw className="spin" size={16} />正在读取 IndexedDB</div> : page.rules.length === 0 ? <div className="proxy-source-rule-loading"><FileText size={18} />没有符合条件的规则</div> : <div className="proxy-source-rule-list">{page.rules.map((rule) => <div key={`${rule.sourceId}:${rule.ordinal}`}>
|
||||
<span>{rule.ordinal + 1}</span><span>{CONDITION_LABELS[rule.condition.type]}</span><code title={rule.raw}>{rule.condition.value}</code><i className={rule.exception ? 'is-exception' : ''}>{rule.exception ? state.proxyProfiles.find((profile) => profile.id === selected.bypassProfileId)?.name : rule.resultProfileName || state.proxyProfiles.find((profile) => profile.id === selected.matchProfileId)?.name}</i>
|
||||
</div>)}</div>}
|
||||
<div className="proxy-source-pagination"><span>{page ? page.total === 0 ? '0 / 0' : `${page.offset + 1}-${Math.min(page.offset + page.limit, page.total)} / ${page.total.toLocaleString()}` : '—'}</span><div><Button size="icon" variant="ghost" aria-label="上一页" disabled={!page || offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}><ChevronLeft size={15} /></Button><Button size="icon" variant="ghost" aria-label="下一页" disabled={!page || offset + PAGE_SIZE >= page.total} onClick={() => setOffset(offset + PAGE_SIZE)}><ChevronRight size={15} /></Button></div></div>
|
||||
</section>}
|
||||
</section>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ProxyConditionType, ProxyProfile, ProxyRuleSourceFormat } from '@/types/models';
|
||||
|
||||
export const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
|
||||
direct: '直接连接',
|
||||
system: '系统代理',
|
||||
fixed_servers: '固定代理',
|
||||
pac_script: 'PAC Script',
|
||||
};
|
||||
|
||||
export const CONDITION_LABELS: Record<ProxyConditionType, string> = {
|
||||
host_exact: '精确域名',
|
||||
host_suffix: '域名及子域',
|
||||
host_wildcard: '域名通配符',
|
||||
host_regex: '域名正则',
|
||||
url_prefix: 'URL 前缀',
|
||||
url_wildcard: 'URL 通配符',
|
||||
url_regex: 'URL 正则',
|
||||
keyword: 'URL 关键词',
|
||||
};
|
||||
|
||||
export const SOURCE_FORMAT_LABELS: Record<ProxyRuleSourceFormat, string> = {
|
||||
auto: '自动识别',
|
||||
autoproxy: 'AutoProxy / GFWList',
|
||||
switchyomega: 'SwitchyOmega Conditions',
|
||||
hosts: '域名 / Hosts 列表',
|
||||
};
|
||||
|
||||
export function proxyProfileDetail(profile: ProxyProfile): string {
|
||||
if (profile.kind === 'fixed_servers') return `${profile.scheme || 'http'}://${profile.host}:${profile.port}`;
|
||||
if (profile.kind === 'pac_script') return profile.pacUrl || '内联 PAC';
|
||||
return PROXY_KIND_LABELS[profile.kind];
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes > 100 * 1024 ? 0 : 1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function relativeTime(timestamp?: number): string {
|
||||
if (!timestamp) return '尚未更新';
|
||||
const delta = Date.now() - timestamp;
|
||||
if (delta < 60_000) return '刚刚';
|
||||
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} 分钟前`;
|
||||
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} 小时前`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import type { ActiveTabInfo, ExtensionState } from '@/types/models';
|
||||
|
||||
export type ProxyRunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
export interface ProxyViewProps {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
run: ProxyRunTask;
|
||||
busy: boolean;
|
||||
tab?: ActiveTabInfo;
|
||||
}
|
||||
Reference in New Issue
Block a user