mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
Update project structure and dependencies; add architecture documentation and improve build scripts. Introduce new versioning and permissions for enhanced functionality.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type {
|
||||
AgentActionRecord, AgentActionState, AgentRuntime, AgentRuntimeState, BridgeGrant,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
interface StorageArea {
|
||||
get(keys: string | string[]): Promise<Record<string, unknown>>;
|
||||
set(items: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_ACTIONS = 200;
|
||||
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let fallbackRuntime: AgentRuntime | undefined;
|
||||
|
||||
function emptyRuntime(): AgentRuntime {
|
||||
return { state: 'idle', updatedAt: Date.now(), actions: [] };
|
||||
}
|
||||
|
||||
function normalizeRuntime(input: unknown): AgentRuntime {
|
||||
if (!input || typeof input !== 'object') return emptyRuntime();
|
||||
const value = input as Partial<AgentRuntime>;
|
||||
return {
|
||||
state: value.state || 'idle',
|
||||
taskId: value.taskId,
|
||||
grantId: value.grantId,
|
||||
startedAt: value.startedAt,
|
||||
pausedAt: value.pausedAt,
|
||||
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : Date.now(),
|
||||
actions: Array.isArray(value.actions) ? value.actions.slice(-MAX_ACTIONS) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentRuntime(): Promise<AgentRuntime> {
|
||||
if (!sessionStorage) return fallbackRuntime || emptyRuntime();
|
||||
return normalizeRuntime((await sessionStorage.get(AGENT_RUNTIME_STORAGE_KEY))[AGENT_RUNTIME_STORAGE_KEY]);
|
||||
}
|
||||
|
||||
async function mutate(updater: (current: AgentRuntime) => AgentRuntime | Promise<AgentRuntime>): Promise<AgentRuntime> {
|
||||
let resolveResult!: (runtime: AgentRuntime) => void;
|
||||
let rejectResult!: (error: unknown) => void;
|
||||
const result = new Promise<AgentRuntime>((resolve, reject) => {
|
||||
resolveResult = resolve;
|
||||
rejectResult = reject;
|
||||
});
|
||||
queue = queue.then(async () => {
|
||||
try {
|
||||
const next = normalizeRuntime(await updater(await getAgentRuntime()));
|
||||
fallbackRuntime = next;
|
||||
await sessionStorage?.set({ [AGENT_RUNTIME_STORAGE_KEY]: next });
|
||||
resolveResult(next);
|
||||
} catch (error) {
|
||||
rejectResult(error);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function startAgentRuntime(grant: BridgeGrant): Promise<AgentRuntime> {
|
||||
const now = Date.now();
|
||||
return mutate((current) => ({
|
||||
state: 'running', taskId: grant.taskId, grantId: grant.id, startedAt: now,
|
||||
updatedAt: now, actions: current.grantId === grant.id ? current.actions : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function setAgentRuntimeState(state: AgentRuntimeState, grant?: BridgeGrant): Promise<AgentRuntime> {
|
||||
return mutate((current) => ({
|
||||
...current,
|
||||
state,
|
||||
taskId: grant?.taskId || current.taskId,
|
||||
grantId: grant?.id || current.grantId,
|
||||
pausedAt: state === 'paused' ? Date.now() : undefined,
|
||||
updatedAt: Date.now(),
|
||||
actions: ['revoked', 'expired'].includes(state)
|
||||
? current.actions.map((action) => action.state === 'running'
|
||||
? { ...action, state: 'cancelled', completedAt: Date.now(), durationMs: Date.now() - action.startedAt, errorCode: state }
|
||||
: action)
|
||||
: current.actions,
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearAgentActions(): Promise<AgentRuntime> {
|
||||
return mutate((current) => ({ ...current, actions: [], updatedAt: Date.now() }));
|
||||
}
|
||||
|
||||
export async function beginAgentAction(
|
||||
grant: BridgeGrant,
|
||||
input: { requestId: string; method: string; targetTabId?: number },
|
||||
): Promise<AgentActionRecord> {
|
||||
let created!: AgentActionRecord;
|
||||
await mutate((current) => {
|
||||
const runtime = current.grantId === grant.id
|
||||
? current
|
||||
: { state: 'running' as const, taskId: grant.taskId, grantId: grant.id, startedAt: Date.now(), updatedAt: Date.now(), actions: [] };
|
||||
if (runtime.state === 'paused' || runtime.state === 'waiting_for_human') {
|
||||
throw new ExtensionError('agent_paused', runtime.state === 'waiting_for_human' ? 'Agent 正在等待用户完成接管步骤' : 'Agent 操作已被用户暂停');
|
||||
}
|
||||
if (runtime.state !== 'running') throw new ExtensionError('grant_expired', 'Agent 会话已经结束');
|
||||
created = {
|
||||
id: crypto.randomUUID(), requestId: input.requestId, taskId: grant.taskId, grantId: grant.id,
|
||||
method: input.method, targetTabId: input.targetTabId, state: 'running', startedAt: Date.now(),
|
||||
};
|
||||
return { ...runtime, updatedAt: Date.now(), actions: [...runtime.actions, created].slice(-MAX_ACTIONS) };
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
export function finishAgentAction(id: string, state: Exclude<AgentActionState, 'running'>, errorCode?: string): Promise<AgentRuntime> {
|
||||
const now = Date.now();
|
||||
return mutate((current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
actions: current.actions.map((action) => action.id === id && action.state === 'running'
|
||||
? { ...action, state, completedAt: now, durationMs: now - action.startedAt, errorCode }
|
||||
: action),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BrowserCookie, CookieInput, CookieRemoveInput } from '@/types/models';
|
||||
|
||||
function toCookie(cookie: Browser.cookies.Cookie): BrowserCookie {
|
||||
const extended = cookie as Browser.cookies.Cookie & {
|
||||
firstPartyDomain?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
sameParty?: boolean;
|
||||
};
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
session: cookie.session,
|
||||
expirationDate: cookie.expirationDate,
|
||||
hostOnly: cookie.hostOnly,
|
||||
storeId: cookie.storeId,
|
||||
firstPartyDomain: extended.firstPartyDomain || undefined,
|
||||
partitionKey: cookie.partitionKey,
|
||||
priority: extended.priority,
|
||||
sameParty: extended.sameParty,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCookies(url: string): Promise<BrowserCookie[]> {
|
||||
const cookies = await browser.cookies.getAll({ url, partitionKey: {} }).catch(() => browser.cookies.getAll({ url }));
|
||||
return cookies.map(toCookie).sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function setCookie(input: CookieInput): Promise<BrowserCookie> {
|
||||
const details = {
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
domain: input.domain || undefined,
|
||||
path: input.path || '/',
|
||||
secure: input.secure,
|
||||
httpOnly: input.httpOnly,
|
||||
sameSite: input.sameSite || 'unspecified',
|
||||
expirationDate: input.expirationDate,
|
||||
storeId: input.storeId,
|
||||
...(input.firstPartyDomain ? { firstPartyDomain: input.firstPartyDomain } : {}),
|
||||
partitionKey: input.partitionKey,
|
||||
} as Parameters<typeof browser.cookies.set>[0];
|
||||
const cookie = await browser.cookies.set(details);
|
||||
if (!cookie) throw new Error('Cookie 写入失败');
|
||||
return toCookie(cookie);
|
||||
}
|
||||
|
||||
export async function removeCookie(input: CookieRemoveInput): Promise<void> {
|
||||
const result = await browser.cookies.remove(input);
|
||||
if (!result) throw new Error('Cookie 不存在或删除失败');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { vi, describe, expect, it } from 'vitest';
|
||||
|
||||
vi.mock('wxt/browser', () => ({ browser: {} }));
|
||||
|
||||
import type { BrowserCookie } from '@/types/models';
|
||||
import { buildCookieUrl, exportCookies } from './transfer';
|
||||
|
||||
const cookie = {
|
||||
name: 'session', value: 'secret-value', domain: '.example.test', path: '/', secure: true,
|
||||
httpOnly: true, hostOnly: false, session: false, sameSite: 'lax', storeId: '0',
|
||||
} as BrowserCookie;
|
||||
|
||||
describe('Cookie transfer', () => {
|
||||
it('constructs a domain/path aware URL', () => {
|
||||
expect(buildCookieUrl('http://app.example.test/start', { domain: '.example.test', path: 'api', secure: true }))
|
||||
.toBe('https://example.test/api');
|
||||
});
|
||||
|
||||
it('redacts exports unless values are explicitly requested', () => {
|
||||
expect(exportCookies([cookie], 'json', false)).toContain('[REDACTED]');
|
||||
expect(exportCookies([cookie], 'netscape', false)).not.toContain('secret-value');
|
||||
expect(exportCookies([cookie], 'set-cookie', true)).toContain('session=secret-value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type {
|
||||
BrowserCookie, CookieImportResult, CookieInput, CookieTransferFormat,
|
||||
} from '@/types/models';
|
||||
import { setCookie } from '@/features/cookies/service';
|
||||
|
||||
const MAX_COOKIES = 1_000;
|
||||
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function assertTransferSize(text: string): void {
|
||||
if (new TextEncoder().encode(text).byteLength > MAX_TRANSFER_BYTES) throw new Error('Cookie 导入内容超过 2 MiB');
|
||||
}
|
||||
|
||||
export function buildCookieUrl(baseUrl: string, input: Pick<CookieInput, 'domain' | 'path' | 'secure'>): string {
|
||||
const base = new URL(baseUrl);
|
||||
const host = input.domain?.replace(/^\./, '') || base.hostname;
|
||||
const protocol = input.secure ? 'https:' : base.protocol === 'https:' ? 'https:' : 'http:';
|
||||
const path = input.path?.startsWith('/') ? input.path : `/${input.path || ''}`;
|
||||
return `${protocol}//${host}${path}`;
|
||||
}
|
||||
|
||||
function sameSite(value: unknown): CookieInput['sameSite'] {
|
||||
const normalized = String(value || '').toLowerCase().replace('none', 'no_restriction');
|
||||
return ['lax', 'strict', 'no_restriction', 'unspecified'].includes(normalized)
|
||||
? normalized as CookieInput['sameSite']
|
||||
: 'unspecified';
|
||||
}
|
||||
|
||||
function fromRecord(value: unknown, baseUrl: string, warnings: string[]): CookieInput | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.name !== 'string' || typeof input.value !== 'string' || input.name.length > 4_096 || input.value.length > 64 * 1_024) return undefined;
|
||||
const output: CookieInput = {
|
||||
url: baseUrl,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
domain: typeof input.domain === 'string' ? input.domain.slice(0, 253) : undefined,
|
||||
path: typeof input.path === 'string' ? input.path.slice(0, 4_096) : '/',
|
||||
secure: input.secure === true,
|
||||
httpOnly: input.httpOnly === true,
|
||||
sameSite: sameSite(input.sameSite),
|
||||
expirationDate: typeof input.expirationDate === 'number' && Number.isFinite(input.expirationDate) ? input.expirationDate : undefined,
|
||||
storeId: typeof input.storeId === 'string' ? input.storeId.slice(0, 240) : undefined,
|
||||
};
|
||||
if (input.partitionKey && typeof input.partitionKey === 'object') {
|
||||
const partition = input.partitionKey as Record<string, unknown>;
|
||||
output.partitionKey = {
|
||||
topLevelSite: typeof partition.topLevelSite === 'string' ? partition.topLevelSite.slice(0, 8_192) : undefined,
|
||||
hasCrossSiteAncestor: partition.hasCrossSiteAncestor === true,
|
||||
};
|
||||
}
|
||||
if (input.priority || input.sameParty) warnings.push(`${input.name}: Priority/SameParty 无法通过浏览器 Cookies API 写回`);
|
||||
output.url = buildCookieUrl(baseUrl, output);
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseJSON(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error('JSON Cookie 必须是数组');
|
||||
if (parsed.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
|
||||
return parsed.map((item) => fromRecord(item, baseUrl, warnings)).filter((item): item is CookieInput => Boolean(item));
|
||||
}
|
||||
|
||||
function parseNetscape(text: string, baseUrl: string): CookieInput[] {
|
||||
const output: CookieInput[] = [];
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const httpOnly = rawLine.startsWith('#HttpOnly_');
|
||||
if ((!httpOnly && rawLine.trim().startsWith('#')) || !rawLine.trim()) continue;
|
||||
const line = httpOnly ? rawLine.slice('#HttpOnly_'.length) : rawLine;
|
||||
const fields = line.split('\t');
|
||||
if (fields.length < 7) continue;
|
||||
const [domain, , path, secure, expiration, name, ...value] = fields;
|
||||
const item: CookieInput = {
|
||||
url: baseUrl, name, value: value.join('\t'), domain, path: path || '/', secure: secure.toUpperCase() === 'TRUE', httpOnly,
|
||||
expirationDate: Number(expiration) > 0 ? Number(expiration) : undefined,
|
||||
sameSite: 'unspecified',
|
||||
};
|
||||
item.url = buildCookieUrl(baseUrl, item);
|
||||
output.push(item);
|
||||
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseSetCookie(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
|
||||
const output: CookieInput[] = [];
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/^set-cookie:\s*/i, '').trim();
|
||||
if (!line) continue;
|
||||
const [pair, ...attributes] = line.split(';').map((part) => part.trim());
|
||||
const separator = pair.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const item: CookieInput = { url: baseUrl, name: pair.slice(0, separator), value: pair.slice(separator + 1), path: '/', sameSite: 'unspecified' };
|
||||
for (const attribute of attributes) {
|
||||
const [rawName, ...rawValue] = attribute.split('=');
|
||||
const name = rawName.toLowerCase();
|
||||
const value = rawValue.join('=');
|
||||
if (name === 'domain') item.domain = value;
|
||||
else if (name === 'path') item.path = value || '/';
|
||||
else if (name === 'secure') item.secure = true;
|
||||
else if (name === 'httponly') item.httpOnly = true;
|
||||
else if (name === 'samesite') item.sameSite = sameSite(value);
|
||||
else if (name === 'expires') {
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isFinite(timestamp)) item.expirationDate = timestamp / 1_000;
|
||||
} else if (name === 'max-age' && Number.isFinite(Number(value))) item.expirationDate = Date.now() / 1_000 + Number(value);
|
||||
else if (name === 'partitioned') item.partitionKey = { topLevelSite: new URL(baseUrl).origin };
|
||||
else if (name === 'priority' || name === 'sameparty') warnings.push(`${item.name}: ${rawName} 无法通过浏览器 Cookies API 写回`);
|
||||
}
|
||||
item.url = buildCookieUrl(baseUrl, item);
|
||||
output.push(item);
|
||||
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function importCookies(baseUrl: string, format: CookieTransferFormat, text: string): Promise<CookieImportResult> {
|
||||
assertTransferSize(text);
|
||||
const warnings: string[] = [];
|
||||
const cookies = format === 'json' ? parseJSON(text, baseUrl, warnings)
|
||||
: format === 'netscape' ? parseNetscape(text, baseUrl)
|
||||
: parseSetCookie(text, baseUrl, warnings);
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
await setCookie(cookie);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
if (warnings.length < 50) warnings.push(`${cookie.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (cookies.length === 0) warnings.push('没有解析到可导入的 Cookie');
|
||||
return { imported, failed, warnings: warnings.slice(0, 50) };
|
||||
}
|
||||
|
||||
function displayValue(cookie: BrowserCookie, includeValues: boolean): string {
|
||||
return includeValues ? cookie.value : '[REDACTED]';
|
||||
}
|
||||
|
||||
export function exportCookies(cookies: BrowserCookie[], format: CookieTransferFormat, includeValues: boolean): string {
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(cookies.map((cookie) => ({ ...cookie, value: displayValue(cookie, includeValues) })), null, 2);
|
||||
}
|
||||
if (format === 'netscape') {
|
||||
const lines = ['# Netscape HTTP Cookie File', '# Exported by Yakit Browser Agent'];
|
||||
for (const cookie of cookies) {
|
||||
const domain = `${cookie.httpOnly ? '#HttpOnly_' : ''}${cookie.domain}`;
|
||||
lines.push([domain, cookie.hostOnly ? 'FALSE' : 'TRUE', cookie.path, cookie.secure ? 'TRUE' : 'FALSE', Math.floor(cookie.expirationDate || 0), cookie.name, displayValue(cookie, includeValues)].join('\t'));
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
return cookies.map((cookie) => {
|
||||
const attributes = [`Path=${cookie.path}`];
|
||||
if (!cookie.hostOnly) attributes.push(`Domain=${cookie.domain}`);
|
||||
if (cookie.expirationDate) attributes.push(`Expires=${new Date(cookie.expirationDate * 1_000).toUTCString()}`);
|
||||
if (cookie.secure) attributes.push('Secure');
|
||||
if (cookie.httpOnly) attributes.push('HttpOnly');
|
||||
if (cookie.sameSite && cookie.sameSite !== 'unspecified') attributes.push(`SameSite=${cookie.sameSite === 'no_restriction' ? 'None' : cookie.sameSite}`);
|
||||
if (cookie.partitionKey) attributes.push('Partitioned');
|
||||
if (cookie.priority) attributes.push(`Priority=${cookie.priority}`);
|
||||
if (cookie.sameParty) attributes.push('SameParty');
|
||||
return `Set-Cookie: ${cookie.name}=${displayValue(cookie, includeValues)}; ${attributes.join('; ')}`;
|
||||
}).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { AuditEvent } from '@/types/models';
|
||||
import { AUDIT_STORAGE_KEY } from '@/protocol/storage';
|
||||
|
||||
const MAX_AUDIT_EVENTS = 500;
|
||||
let auditQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export type NewAuditEvent = Omit<AuditEvent, 'id' | 'timestamp'>;
|
||||
|
||||
export function appendAuditEvent(input: NewAuditEvent): Promise<void> {
|
||||
const operation = auditQueue.then(async () => {
|
||||
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
|
||||
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
|
||||
const event: AuditEvent = { id: crypto.randomUUID(), timestamp: Date.now(), ...input };
|
||||
await browser.storage.local.set({ [AUDIT_STORAGE_KEY]: [...current, event].slice(-MAX_AUDIT_EVENTS) });
|
||||
});
|
||||
auditQueue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
|
||||
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
|
||||
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
|
||||
return current.slice(-Math.min(Math.max(limit, 1), MAX_AUDIT_EVENTS)).reverse();
|
||||
}
|
||||
|
||||
export async function clearAuditEvents(): Promise<void> {
|
||||
const operation = auditQueue.then(() => browser.storage.local.remove(AUDIT_STORAGE_KEY));
|
||||
auditQueue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { STATE_STORAGE_KEYS } from '@/protocol/storage';
|
||||
import type { BridgeStatus, DiagnosticsBundle } from '@/types/models';
|
||||
import { listAuditEvents } from '@/features/diagnostics/audit';
|
||||
import { getState } from '@/platform/storage/state';
|
||||
import { getEnterprisePolicy } from '@/platform/policy/managed';
|
||||
import { getRuntimeMetrics } from './metrics';
|
||||
|
||||
export async function createDiagnosticsBundle(bridge: BridgeStatus): Promise<DiagnosticsBundle> {
|
||||
const manifest = browser.runtime.getManifest();
|
||||
const sessionArea = (browser.storage as unknown as { session?: { get(keys: string[]): Promise<Record<string, unknown>> } }).session;
|
||||
const [state, platform, policy, metrics, audit, local, session] = await Promise.all([
|
||||
getState(), browser.runtime.getPlatformInfo(), getEnterprisePolicy(), getRuntimeMetrics(), listAuditEvents(100),
|
||||
browser.storage.local.get([...STATE_STORAGE_KEYS]),
|
||||
sessionArea?.get([...STATE_STORAGE_KEYS]) || Promise.resolve({}),
|
||||
]);
|
||||
const { taskId: _taskId, grantId: _grantId, ...safeBridge } = bridge;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: Date.now(),
|
||||
extension: {
|
||||
version: manifest.version,
|
||||
manifestVersion: manifest.manifest_version,
|
||||
buildChannel: import.meta.env.MODE,
|
||||
permissions: [...(manifest.permissions || [])].sort(),
|
||||
},
|
||||
platform: { os: platform.os, arch: platform.arch },
|
||||
bridge: safeBridge,
|
||||
policy,
|
||||
state: {
|
||||
proxyProfiles: state.proxyProfiles.length,
|
||||
proxyRules: state.proxyRules.length,
|
||||
userAgentRules: state.userAgentRules.length,
|
||||
floatingPanelEnabled: state.floatingPanel.enabled,
|
||||
activeGrant: Boolean(state.activeGrant),
|
||||
activeGrantTargets: state.activeGrant?.targets.length || 0,
|
||||
activeGrantScopes: state.activeGrant?.scopes || [],
|
||||
handoffState: state.handoff?.state,
|
||||
},
|
||||
storageDomains: Object.fromEntries(STATE_STORAGE_KEYS.map((key) => [key, key in local || key in session])),
|
||||
metrics,
|
||||
recentAudit: audit.map(({ timestamp, category, action, outcome, durationMs, errorCode }) => ({
|
||||
timestamp, category, action, outcome, durationMs, errorCode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { RUNTIME_METRICS_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type { RuntimeMetrics } from '@/types/models';
|
||||
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
function defaults(): RuntimeMetrics {
|
||||
const now = Date.now();
|
||||
return {
|
||||
version: 1, firstSeenAt: now, updatedAt: now, serviceWorkerStarts: 0,
|
||||
bridgeConnectAttempts: 0, bridgeConnections: 0, bridgeDisconnects: 0, bridgeErrors: 0,
|
||||
heartbeatSamples: 0, heartbeatLatencyTotalMs: 0, heartbeatLatencyMaxMs: 0, capabilities: {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRuntimeMetrics(): Promise<RuntimeMetrics> {
|
||||
const stored = (await browser.storage.local.get(RUNTIME_METRICS_STORAGE_KEY))[RUNTIME_METRICS_STORAGE_KEY];
|
||||
if (!stored || typeof stored !== 'object') return defaults();
|
||||
return { ...defaults(), ...(stored as Partial<RuntimeMetrics>), capabilities: (stored as RuntimeMetrics).capabilities || {} };
|
||||
}
|
||||
|
||||
function mutate(updater: (current: RuntimeMetrics) => RuntimeMetrics): void {
|
||||
queue = queue.then(async () => {
|
||||
const next = updater(await getRuntimeMetrics());
|
||||
next.updatedAt = Date.now();
|
||||
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function recordServiceWorkerStart(): void {
|
||||
mutate((current) => ({ ...current, serviceWorkerStarts: current.serviceWorkerStarts + 1 }));
|
||||
}
|
||||
|
||||
export function recordBridgeState(state: 'connecting' | 'connected' | 'disconnected' | 'error'): void {
|
||||
mutate((current) => ({
|
||||
...current,
|
||||
bridgeConnectAttempts: current.bridgeConnectAttempts + (state === 'connecting' ? 1 : 0),
|
||||
bridgeConnections: current.bridgeConnections + (state === 'connected' ? 1 : 0),
|
||||
bridgeDisconnects: current.bridgeDisconnects + (state === 'disconnected' ? 1 : 0),
|
||||
bridgeErrors: current.bridgeErrors + (state === 'error' ? 1 : 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function recordHeartbeat(latencyMs: number): void {
|
||||
const bounded = Math.min(Math.max(Math.round(latencyMs), 0), 60_000);
|
||||
mutate((current) => ({
|
||||
...current,
|
||||
heartbeatSamples: current.heartbeatSamples + 1,
|
||||
heartbeatLatencyTotalMs: current.heartbeatLatencyTotalMs + bounded,
|
||||
heartbeatLatencyMaxMs: Math.max(current.heartbeatLatencyMaxMs, bounded),
|
||||
}));
|
||||
}
|
||||
|
||||
export function recordCapabilityMetric(method: string, durationMs: number, error: boolean): void {
|
||||
mutate((current) => {
|
||||
const previous = current.capabilities[method] || { count: 0, errorCount: 0, totalDurationMs: 0, maxDurationMs: 0 };
|
||||
const duration = Math.min(Math.max(Math.round(durationMs), 0), 60_000);
|
||||
return {
|
||||
...current,
|
||||
capabilities: {
|
||||
...current.capabilities,
|
||||
[method]: {
|
||||
count: previous.count + 1,
|
||||
errorCount: previous.errorCount + (error ? 1 : 0),
|
||||
totalDurationMs: previous.totalDurationMs + duration,
|
||||
maxDurationMs: Math.max(previous.maxDurationMs, duration),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetRuntimeMetrics(): Promise<RuntimeMetrics> {
|
||||
const next = defaults();
|
||||
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import {
|
||||
clientAuthPayload, engineChallengePayload, pairingVerificationCode, signBridgePayload, verifyBridgePayload,
|
||||
} from './identity';
|
||||
|
||||
describe('Bridge v3 identity transcript', () => {
|
||||
it('keeps the Go-compatible canonical field order', () => {
|
||||
expect(engineChallengePayload({
|
||||
engineIdentityId: 'identity-1', engineInstanceId: 'instance-1', challenge: 'nonce-1', timestamp: 123,
|
||||
})).toBe('yak-browser-bridge-v3\nengine-challenge\nidentity-1\ninstance-1\nnonce-1\n123');
|
||||
const envelope: BridgeEnvelope = {
|
||||
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
|
||||
capabilities: ['z.capability', 'a.capability'], taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
|
||||
};
|
||||
expect(clientAuthPayload({
|
||||
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
|
||||
challenge: 'nonce-1', envelope,
|
||||
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\ntask-1\ngrant-1\nsession-1');
|
||||
});
|
||||
|
||||
it('matches the shared pairing verification vector', async () => {
|
||||
await expect(pairingVerificationCode({
|
||||
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
|
||||
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
|
||||
publicKey: { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' },
|
||||
})).resolves.toBe('113961');
|
||||
});
|
||||
|
||||
it('signs and verifies ECDSA P-256 payloads', async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
const publicKey = { kty: 'EC' as const, crv: 'P-256' as const, x: publicJWK.x!, y: publicJWK.y! };
|
||||
const signature = await signBridgePayload(pair.privateKey, 'payload');
|
||||
await expect(verifyBridgePayload(publicKey, 'payload', signature)).resolves.toBe(true);
|
||||
await expect(verifyBridgePayload(publicKey, 'tampered', signature)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgePublicKey } from '@/types/models';
|
||||
|
||||
const DATABASE_NAME = 'yakit-browser-bridge-identity-v1';
|
||||
const STORE_NAME = 'identities';
|
||||
|
||||
interface StoredBrowserIdentity {
|
||||
installationId: string;
|
||||
privateKey: CryptoKey;
|
||||
publicKey: BridgePublicKey;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function openIdentityDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME, { keyPath: 'installationId' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('无法打开浏览器配对身份数据库'));
|
||||
});
|
||||
}
|
||||
|
||||
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('浏览器配对身份数据库操作失败'));
|
||||
});
|
||||
}
|
||||
|
||||
async function readIdentity(installationId: string): Promise<StoredBrowserIdentity | undefined> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
return await requestResult(transaction.objectStore(STORE_NAME).get(installationId)) as StoredBrowserIdentity | undefined;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIdentity(identity: StoredBrowserIdentity): Promise<void> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
await requestResult(transaction.objectStore(STORE_NAME).put(identity));
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearBrowserBridgeIdentity(installationId: string): Promise<void> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
await requestResult(transaction.objectStore(STORE_NAME).delete(installationId));
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublicJWK(value: JsonWebKey): BridgePublicKey {
|
||||
if (value.kty !== 'EC' || value.crv !== 'P-256' || !value.x || !value.y) throw new Error('浏览器配对公钥不是 ECDSA P-256');
|
||||
return { kty: 'EC', crv: 'P-256', x: value.x, y: value.y };
|
||||
}
|
||||
|
||||
export async function getOrCreateBrowserBridgeIdentity(installationId: string): Promise<StoredBrowserIdentity> {
|
||||
const existing = await readIdentity(installationId);
|
||||
if (existing?.privateKey && existing.publicKey) return existing;
|
||||
const generated = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const [publicJWK, privatePKCS8] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', generated.publicKey),
|
||||
crypto.subtle.exportKey('pkcs8', generated.privateKey),
|
||||
]);
|
||||
const privateKey = await crypto.subtle.importKey('pkcs8', privatePKCS8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
||||
const identity: StoredBrowserIdentity = {
|
||||
installationId,
|
||||
privateKey,
|
||||
publicKey: normalizePublicJWK(publicJWK),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await writeIdentity(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function bytesToBase64URL(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
|
||||
}
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64URLToBytes(value: string): Uint8Array {
|
||||
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function randomBridgeNonce(): string {
|
||||
return bytesToBase64URL(crypto.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
|
||||
export async function signBridgePayload(privateKey: CryptoKey, payload: string): Promise<string> {
|
||||
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(payload));
|
||||
return bytesToBase64URL(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export async function verifyBridgePayload(publicKey: BridgePublicKey, payload: string, signature: string): Promise<boolean> {
|
||||
const key = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' }, key,
|
||||
base64URLToBytes(signature).buffer as ArrayBuffer,
|
||||
new TextEncoder().encode(payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function engineChallengePayload(input: {
|
||||
engineIdentityId: string;
|
||||
engineInstanceId: string;
|
||||
challenge: string;
|
||||
timestamp: number;
|
||||
}): string {
|
||||
return [
|
||||
'yak-browser-bridge-v3', 'engine-challenge', input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, String(input.timestamp),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function clientAuthPayload(input: {
|
||||
origin: string;
|
||||
engineIdentityId: string;
|
||||
engineInstanceId: string;
|
||||
challenge: string;
|
||||
envelope: BridgeEnvelope;
|
||||
}): string {
|
||||
return [
|
||||
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
|
||||
[...(input.envelope.capabilities || [])].sort().join(','), input.envelope.taskId || '', input.envelope.grantId || '',
|
||||
input.envelope.resumeSessionId || '',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function pairingVerificationCode(input: {
|
||||
engineIdentityId: string;
|
||||
requestId: string;
|
||||
origin: string;
|
||||
installationId: string;
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
}): Promise<string> {
|
||||
const payload = [
|
||||
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
|
||||
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
|
||||
].join('\n');
|
||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
|
||||
let value = 0n;
|
||||
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
|
||||
return String(value % 1_000_000n).padStart(6, '0');
|
||||
}
|
||||
|
||||
export function publicKeysEqual(left: BridgePublicKey, right: BridgePublicKey): boolean {
|
||||
return left.kty === right.kty && left.crv === right.crv && left.x === right.x && left.y === right.y;
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
|
||||
import { BRIDGE_CAPABILITIES } from '@/protocol/capabilities';
|
||||
import {
|
||||
BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS,
|
||||
BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope,
|
||||
parseBridgePairingEnvelope, type BridgePairingEnvelope,
|
||||
} from '@/protocol/bridge';
|
||||
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
|
||||
import { routeCapability } from '@/features/grants/service';
|
||||
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
|
||||
import { recordBridgeState, recordCapabilityMetric, recordHeartbeat } from '@/features/diagnostics/metrics';
|
||||
import {
|
||||
clearBrowserBridgeIdentity, clientAuthPayload, engineChallengePayload, getOrCreateBrowserBridgeIdentity,
|
||||
pairingVerificationCode, publicKeysEqual, randomBridgeNonce, signBridgePayload, verifyBridgePayload,
|
||||
} from './identity';
|
||||
|
||||
const STATUS_EVENT = 'bridge.status.changed';
|
||||
const PAIRING_STATUS_EVENT = 'bridge.pairing.status.changed';
|
||||
const RECONNECT_DELAY = 3_000;
|
||||
const HEARTBEAT_INTERVAL = 20_000;
|
||||
const HANDSHAKE_TIMEOUT = 5_000;
|
||||
const MAX_CONCURRENT_REQUESTS = 8;
|
||||
const ENGINE_REQUEST_TIMEOUT = 10_000;
|
||||
const MAX_OUTGOING_REQUESTS = 4;
|
||||
|
||||
interface OutgoingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof globalThis.setTimeout>;
|
||||
}
|
||||
|
||||
interface ChunkAssembly {
|
||||
createdAt: number;
|
||||
total: number;
|
||||
originalBytes: number;
|
||||
parts: Array<Uint8Array | undefined>;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isLoopbackEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
return (url.protocol === 'ws:' || url.protocol === 'wss:')
|
||||
&& ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class EngineBridge {
|
||||
private socket?: WebSocket;
|
||||
private nativePort?: Browser.runtime.Port;
|
||||
private reconnectTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private heartbeatTimer?: ReturnType<typeof globalThis.setInterval>;
|
||||
private handshakeTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private handshakeResolve?: () => void;
|
||||
private handshakeReject?: (error: Error) => void;
|
||||
private connectPromise?: Promise<void>;
|
||||
private pairingSocket?: WebSocket;
|
||||
private pairingTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private pairingResolve?: (status: BridgePairingStatus) => void;
|
||||
private pairingReject?: (error: Error) => void;
|
||||
private pairingContext?: {
|
||||
config: BridgeConfig;
|
||||
clientNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
privateKey: CryptoKey;
|
||||
requestId?: string;
|
||||
engineIdentityId?: string;
|
||||
enginePublicKey?: BridgePublicKey;
|
||||
};
|
||||
private readonly inFlight = new Map<string, AbortController>();
|
||||
private readonly outgoing = new Map<string, OutgoingRequest>();
|
||||
private readonly chunks = new Map<string, ChunkAssembly>();
|
||||
private heartbeatSequence = 0;
|
||||
private manuallyClosed = false;
|
||||
private status: BridgeStatus = { state: 'disconnected', message: '未连接引擎' };
|
||||
private pairingStatus: BridgePairingStatus = { state: 'idle', message: '尚未配对' };
|
||||
|
||||
getStatus(): BridgeStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getPairingStatus(): BridgePairingStatus {
|
||||
return this.pairingStatus;
|
||||
}
|
||||
|
||||
emitEvent(method: string, params: unknown): void {
|
||||
if (this.status.state === 'connected') this.send({ type: 'event', method, params });
|
||||
}
|
||||
|
||||
requestEngine<T>(method: string, params: unknown, timeoutMs = ENGINE_REQUEST_TIMEOUT): Promise<T> {
|
||||
if (this.status.state !== 'connected') return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎未连接'));
|
||||
if (!this.nativePort && this.socket?.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎连接不可用'));
|
||||
}
|
||||
if (this.outgoing.size >= MAX_OUTGOING_REQUESTS) {
|
||||
return Promise.reject(new ExtensionError('server_busy', `插件到 Yak 的并行请求已达到 ${MAX_OUTGOING_REQUESTS} 个上限`));
|
||||
}
|
||||
if (this.status.capabilities && !this.status.capabilities.includes(method)) {
|
||||
return Promise.reject(new ExtensionError('engine_capability_unavailable', `Yak 引擎不支持能力: ${method}`));
|
||||
}
|
||||
const id = `extension-${crypto.randomUUID()}`;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
this.outgoing.delete(id);
|
||||
this.send({ type: 'cancel', id });
|
||||
reject(new ExtensionError('engine_timeout', `Yak 引擎请求超过 ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.outgoing.set(id, { resolve: (value) => resolve(value as T), reject, timer });
|
||||
try {
|
||||
this.send({ type: 'request', id, method, params });
|
||||
} catch (error) {
|
||||
globalThis.clearTimeout(timer);
|
||||
this.outgoing.delete(id);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async connect(config?: BridgeConfig): Promise<void> {
|
||||
if (this.status.state === 'connected') return;
|
||||
if (this.connectPromise) return this.connectPromise;
|
||||
const effectiveConfig = config || (await getState()).bridge;
|
||||
if (!effectiveConfig.pairedEngine) throw new Error('浏览器插件尚未与 Yak 引擎配对');
|
||||
const attempt = (effectiveConfig.transport === 'native'
|
||||
? this.connectNative(effectiveConfig)
|
||||
: this.connectWebSocket(effectiveConfig)).catch((error) => {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
if (!this.manuallyClosed && this.status.state !== 'error') this.setStatus({ state: 'error', message: failure.message });
|
||||
throw failure;
|
||||
});
|
||||
const tracked = attempt.finally(() => {
|
||||
if (this.connectPromise === tracked) this.connectPromise = undefined;
|
||||
});
|
||||
this.connectPromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manuallyClosed = true;
|
||||
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
|
||||
this.failHandshake(new Error('Bridge 连接已取消'));
|
||||
this.stopHeartbeat();
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Bridge 已断开'));
|
||||
this.socket?.close(1000, 'user disconnected');
|
||||
this.socket = undefined;
|
||||
this.nativePort?.disconnect();
|
||||
this.nativePort = undefined;
|
||||
this.setStatus({ state: 'disconnected', message: '已手动断开' });
|
||||
}
|
||||
|
||||
cancelActiveRequests(): void {
|
||||
this.abortInFlight();
|
||||
}
|
||||
|
||||
private async connectWebSocket(config: BridgeConfig): Promise<void> {
|
||||
if (!isLoopbackEndpoint(config.endpoint)) {
|
||||
throw new Error('Bridge 仅允许连接本机 ws://127.0.0.1、localhost 或 ::1');
|
||||
}
|
||||
this.manuallyClosed = false;
|
||||
this.setStatus({ state: 'connecting', message: '正在连接本地 Yak 引擎' });
|
||||
const socket = new WebSocket(config.endpoint);
|
||||
this.socket = socket;
|
||||
const negotiated = this.createHandshakePromise();
|
||||
|
||||
socket.addEventListener('open', () => this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' }));
|
||||
socket.addEventListener('message', (event) => void this.onMessage(String(event.data)));
|
||||
socket.addEventListener('error', () => {
|
||||
const error = new Error('Bridge 连接失败');
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
});
|
||||
socket.addEventListener('close', () => {
|
||||
this.stopHeartbeat();
|
||||
if (this.socket === socket) {
|
||||
this.socket = undefined;
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
|
||||
}
|
||||
this.failHandshake(new Error('Bridge 在协议协商完成前断开'));
|
||||
if (!this.manuallyClosed) this.setStatus({ state: 'disconnected', message: '与 Yak 引擎的连接已断开' });
|
||||
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
|
||||
});
|
||||
return negotiated;
|
||||
}
|
||||
|
||||
private async connectNative(config: BridgeConfig): Promise<void> {
|
||||
if (!config.nativeHost.trim()) throw new Error('Native Messaging Host 名称不能为空');
|
||||
this.manuallyClosed = false;
|
||||
this.setStatus({ state: 'connecting', message: '正在连接 Yakit Native Host' });
|
||||
const port = browser.runtime.connectNative(config.nativeHost.trim());
|
||||
this.nativePort = port;
|
||||
const negotiated = this.createHandshakePromise();
|
||||
port.onMessage.addListener((message) => void this.onMessage(message));
|
||||
port.onDisconnect.addListener(() => {
|
||||
const lastError = browser.runtime.lastError?.message;
|
||||
if (this.nativePort === port) {
|
||||
this.nativePort = undefined;
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
|
||||
}
|
||||
this.failHandshake(new Error(lastError || 'Native Host 在协议协商完成前断开'));
|
||||
this.stopHeartbeat();
|
||||
this.setStatus({ state: lastError ? 'error' : 'disconnected', message: lastError || 'Native Host 已断开' });
|
||||
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
|
||||
});
|
||||
this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' });
|
||||
return negotiated;
|
||||
}
|
||||
|
||||
private async answerChallenge(config: BridgeConfig, challenge: BridgeEnvelope): Promise<void> {
|
||||
const paired = config.pairedEngine;
|
||||
if (!paired || !challenge.publicKey || !challenge.engineIdentityId || !challenge.engineInstanceId || !challenge.challenge || !challenge.signature || !challenge.timestamp) {
|
||||
throw new Error('Yak 引擎返回了不完整的身份挑战');
|
||||
}
|
||||
if (Math.abs(Date.now() - challenge.timestamp) > 60_000) throw new Error('Yak 引擎身份挑战已经过期');
|
||||
if (paired.engineIdentityId !== challenge.engineIdentityId || !publicKeysEqual(paired.publicKey, challenge.publicKey)) {
|
||||
throw new Error('Yak 引擎身份与首次配对记录不一致');
|
||||
}
|
||||
const verified = await verifyBridgePayload(challenge.publicKey, engineChallengePayload({
|
||||
engineIdentityId: challenge.engineIdentityId,
|
||||
engineInstanceId: challenge.engineInstanceId,
|
||||
challenge: challenge.challenge,
|
||||
timestamp: challenge.timestamp,
|
||||
}), challenge.signature);
|
||||
if (!verified) throw new Error('Yak 引擎身份签名验证失败');
|
||||
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
|
||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||
const auth: BridgeEnvelope = {
|
||||
type: 'auth',
|
||||
client: 'yakit-browser-extension',
|
||||
version: browser.runtime.getManifest().version,
|
||||
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
capabilities: [...BRIDGE_CAPABILITIES],
|
||||
installationId: config.installationId,
|
||||
taskId: state.activeGrant?.taskId,
|
||||
grantId: state.activeGrant?.id,
|
||||
resumeSessionId: previousSession?.sessionId,
|
||||
challenge: challenge.challenge,
|
||||
};
|
||||
auth.signature = await signBridgePayload(identity.privateKey, clientAuthPayload({
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''),
|
||||
engineIdentityId: challenge.engineIdentityId,
|
||||
engineInstanceId: challenge.engineInstanceId,
|
||||
challenge: challenge.challenge,
|
||||
envelope: auth,
|
||||
}));
|
||||
this.send(auth);
|
||||
}
|
||||
|
||||
private createHandshakePromise(): Promise<void> {
|
||||
this.failHandshake(new Error('Bridge 协议协商已被新连接替代'));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.handshakeResolve = resolve;
|
||||
this.handshakeReject = reject;
|
||||
this.handshakeTimer = globalThis.setTimeout(() => {
|
||||
const error = new Error(`Bridge 协议协商超过 ${HANDSHAKE_TIMEOUT / 1_000} 秒`);
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
this.socket?.close(1002, 'handshake timeout');
|
||||
this.nativePort?.disconnect();
|
||||
}, HANDSHAKE_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
private async completeHandshake(message: BridgeEnvelope): Promise<void> {
|
||||
if (!this.handshakeResolve) return;
|
||||
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
|
||||
const resolve = this.handshakeResolve;
|
||||
this.handshakeTimer = undefined;
|
||||
this.handshakeResolve = undefined;
|
||||
this.handshakeReject = undefined;
|
||||
this.setStatus({
|
||||
state: 'connected',
|
||||
message: '已连接 Yak 引擎',
|
||||
connectedAt: Date.now(),
|
||||
engineVersion: message.version,
|
||||
protocolVersion: message.protocolVersion,
|
||||
capabilities: message.capabilities,
|
||||
sessionId: message.sessionId,
|
||||
engineInstanceId: message.engineInstanceId,
|
||||
engineIdentityId: message.engineIdentityId,
|
||||
connectionId: message.connectionId,
|
||||
taskId: message.taskId,
|
||||
grantId: message.grantId,
|
||||
resumed: message.resumed,
|
||||
});
|
||||
await setBridgeRuntimeSession({
|
||||
sessionId: message.sessionId!,
|
||||
engineInstanceId: message.engineInstanceId!,
|
||||
engineIdentityId: message.engineIdentityId,
|
||||
taskId: message.taskId,
|
||||
grantId: message.grantId,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
this.startHeartbeat();
|
||||
resolve();
|
||||
}
|
||||
|
||||
private failHandshake(error: Error): void {
|
||||
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
|
||||
const reject = this.handshakeReject;
|
||||
this.handshakeTimer = undefined;
|
||||
this.handshakeResolve = undefined;
|
||||
this.handshakeReject = undefined;
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private async onMessage(raw: unknown): Promise<void> {
|
||||
let message: BridgeEnvelope;
|
||||
try {
|
||||
message = parseBridgeEnvelope(raw);
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
if (this.status.state === 'negotiating') {
|
||||
this.failHandshake(failure);
|
||||
this.setStatus({ state: 'error', message: failure.message });
|
||||
this.socket?.close(1002, 'invalid handshake');
|
||||
this.nativePort?.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'chunk') {
|
||||
try {
|
||||
const assembled = this.acceptChunk(message);
|
||||
if (assembled !== undefined) await this.onMessage(assembled);
|
||||
} catch (error) {
|
||||
this.setStatus({ ...this.status, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'challenge') {
|
||||
try {
|
||||
await this.answerChallenge((await getState()).bridge, message);
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
this.failHandshake(failure);
|
||||
this.setStatus({ state: 'error', message: failure.message });
|
||||
this.socket?.close(1008, 'identity verification failed');
|
||||
this.nativePort?.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'hello_ack') {
|
||||
await this.completeHandshake(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'response' && message.error && this.status.state === 'negotiating') {
|
||||
const error = new Error(message.error.message || 'Bridge 拒绝连接');
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (message.type === 'response' && message.id) {
|
||||
const pending = this.outgoing.get(message.id);
|
||||
if (!pending) return;
|
||||
globalThis.clearTimeout(pending.timer);
|
||||
this.outgoing.delete(message.id);
|
||||
if (message.error) pending.reject(new ExtensionError(message.error.code, message.error.message));
|
||||
else pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'ping') {
|
||||
this.send({
|
||||
type: 'pong', id: message.id, sequence: message.sequence,
|
||||
timestamp: message.timestamp, replyTimestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pong') {
|
||||
const now = Date.now();
|
||||
const latencyMs = Math.max(0, now - Number(message.timestamp));
|
||||
recordHeartbeat(latencyMs);
|
||||
this.setStatus({
|
||||
...this.status,
|
||||
heartbeatSequence: message.sequence,
|
||||
latencyMs,
|
||||
lastHeartbeatAt: now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === 'cancel' && message.id) {
|
||||
this.inFlight.get(message.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (this.status.state !== 'connected' || message.type !== 'request' || !message.id || !message.method) return;
|
||||
|
||||
if (this.inFlight.size >= MAX_CONCURRENT_REQUESTS) {
|
||||
this.send({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code: 'server_busy', message: `Bridge 并行请求已达到 ${MAX_CONCURRENT_REQUESTS} 个上限` },
|
||||
});
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method, outcome: 'denied', errorCode: 'server_busy',
|
||||
targetTabId: typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
|
||||
? (message.params as { tabId: number }).tabId
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.inFlight.has(message.id)) {
|
||||
this.send({
|
||||
type: 'response', id: message.id,
|
||||
error: { code: 'duplicate_request_id', message: 'Bridge 请求 ID 正在使用中' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
this.inFlight.set(message.id, controller);
|
||||
const cancelled = new Promise<never>((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => reject(new ExtensionError('cancelled', 'Bridge 请求已取消')), { once: true });
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
let taskId: string | undefined;
|
||||
let actionId: string | undefined;
|
||||
let targetTabId = typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
|
||||
? (message.params as { tabId: number }).tabId
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const grant = (await getState()).activeGrant;
|
||||
taskId = grant?.taskId;
|
||||
targetTabId ??= grant?.targets[0]?.tabId;
|
||||
if (grant) {
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id, method: message.method, targetTabId,
|
||||
})).id;
|
||||
}
|
||||
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
|
||||
const result = await Promise.race([operation, cancelled]);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
this.send({ type: 'response', id: message.id, result });
|
||||
if (actionId) void finishAgentAction(actionId, 'success');
|
||||
recordCapabilityMetric(message.method, durationMs, false);
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method, outcome: 'success', taskId,
|
||||
targetTabId, durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
recordCapabilityMetric(message.method, performance.now() - startedAt, true);
|
||||
this.send({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code, message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
if (actionId) {
|
||||
void finishAgentAction(
|
||||
actionId,
|
||||
code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
|
||||
code,
|
||||
);
|
||||
}
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method,
|
||||
outcome: code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
|
||||
taskId, targetTabId, errorCode: code,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
});
|
||||
} finally {
|
||||
this.inFlight.delete(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: BridgeEnvelope): void {
|
||||
let encoded = JSON.stringify(message);
|
||||
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) {
|
||||
if (message.type !== 'response' || !message.id) throw new Error('Bridge 出站消息超过 16 MiB 限制');
|
||||
encoded = JSON.stringify({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code: 'payload_too_large', message: 'Bridge 响应超过 16 MiB 限制' },
|
||||
} satisfies BridgeEnvelope);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(encoded);
|
||||
if (bytes.byteLength > BRIDGE_CHUNK_THRESHOLD_BYTES) {
|
||||
const transferId = `chunk-${crypto.randomUUID()}`;
|
||||
const total = Math.ceil(bytes.byteLength / BRIDGE_CHUNK_BYTES);
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const start = index * BRIDGE_CHUNK_BYTES;
|
||||
this.sendRaw(JSON.stringify({
|
||||
type: 'chunk', transferId, index, total, originalBytes: bytes.byteLength,
|
||||
data: bytesToBase64(bytes.subarray(start, Math.min(start + BRIDGE_CHUNK_BYTES, bytes.byteLength))),
|
||||
} satisfies BridgeEnvelope));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.sendRaw(encoded);
|
||||
}
|
||||
|
||||
private sendRaw(encoded: string): void {
|
||||
if (this.nativePort) {
|
||||
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
|
||||
return;
|
||||
}
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoded);
|
||||
}
|
||||
|
||||
private acceptChunk(message: BridgeEnvelope): string | undefined {
|
||||
const now = Date.now();
|
||||
for (const [id, assembly] of this.chunks) {
|
||||
if (now - assembly.createdAt > BRIDGE_CHUNK_TIMEOUT_MS) this.chunks.delete(id);
|
||||
}
|
||||
const transferId = message.transferId!;
|
||||
let assembly = this.chunks.get(transferId);
|
||||
if (!assembly) {
|
||||
if (this.chunks.size >= BRIDGE_MAX_CHUNK_TRANSFERS) throw new Error('Bridge 并行分片传输超过上限');
|
||||
assembly = {
|
||||
createdAt: now, total: message.total!, originalBytes: message.originalBytes!,
|
||||
parts: new Array<Uint8Array | undefined>(message.total!),
|
||||
};
|
||||
this.chunks.set(transferId, assembly);
|
||||
}
|
||||
if (assembly.total !== message.total || assembly.originalBytes !== message.originalBytes) {
|
||||
this.chunks.delete(transferId);
|
||||
throw new Error('Bridge 分片元数据不一致');
|
||||
}
|
||||
const part = base64ToBytes(message.data!);
|
||||
if (part.byteLength > BRIDGE_CHUNK_BYTES || (message.index! < assembly.total - 1 && part.byteLength !== BRIDGE_CHUNK_BYTES)) {
|
||||
this.chunks.delete(transferId);
|
||||
throw new Error('Bridge 分片大小无效');
|
||||
}
|
||||
assembly.parts[message.index!] = part;
|
||||
if (assembly.parts.some((item) => item === undefined)) return undefined;
|
||||
const bytes = new Uint8Array(assembly.originalBytes);
|
||||
let offset = 0;
|
||||
for (const item of assembly.parts) {
|
||||
bytes.set(item!, offset);
|
||||
offset += item!.byteLength;
|
||||
}
|
||||
this.chunks.delete(transferId);
|
||||
if (offset !== assembly.originalBytes) throw new Error('Bridge 分片重组大小不匹配');
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
private scheduleReconnect(config: BridgeConfig): void {
|
||||
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = globalThis.setTimeout(() => void this.connect(config).catch(() => undefined), RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
private abortInFlight(): void {
|
||||
for (const controller of this.inFlight.values()) controller.abort();
|
||||
this.inFlight.clear();
|
||||
this.chunks.clear();
|
||||
}
|
||||
|
||||
private rejectOutgoing(error: Error): void {
|
||||
for (const pending of this.outgoing.values()) {
|
||||
globalThis.clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
const ping = () => {
|
||||
const sequence = ++this.heartbeatSequence;
|
||||
this.send({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: Date.now() });
|
||||
};
|
||||
ping();
|
||||
this.heartbeatTimer = globalThis.setInterval(ping, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) globalThis.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
|
||||
private setStatus(status: BridgeStatus): void {
|
||||
if (status.state !== this.status.state && ['connecting', 'connected', 'disconnected', 'error'].includes(status.state)) {
|
||||
recordBridgeState(status.state as 'connecting' | 'connected' | 'disconnected' | 'error');
|
||||
}
|
||||
this.status = status;
|
||||
void browser.runtime.sendMessage({ action: STATUS_EVENT, payload: status }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async startPairing(): Promise<BridgePairingStatus> {
|
||||
const config = (await getState()).bridge;
|
||||
if (config.pairedEngine) return { state: 'approved', message: '当前浏览器已经完成配对', engineIdentityId: config.pairedEngine.engineIdentityId };
|
||||
if (!isLoopbackEndpoint(config.endpoint)) throw new Error('配对仅允许访问本机 Yak Bridge');
|
||||
if (this.pairingSocket && ['requesting', 'pending'].includes(this.pairingStatus.state)) return this.pairingStatus;
|
||||
this.cancelPairing(false);
|
||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||
const clientNonce = randomBridgeNonce();
|
||||
const pairingURL = new URL(config.endpoint);
|
||||
pairingURL.pathname = '/pairing';
|
||||
pairingURL.search = '';
|
||||
pairingURL.hash = '';
|
||||
const socket = new WebSocket(pairingURL.toString());
|
||||
this.pairingSocket = socket;
|
||||
this.pairingContext = { config, clientNonce, publicKey: identity.publicKey, privateKey: identity.privateKey };
|
||||
this.setPairingStatus({ state: 'requesting', message: '正在向本机 Yak 引擎申请配对' });
|
||||
const pending = new Promise<BridgePairingStatus>((resolve, reject) => {
|
||||
this.pairingResolve = resolve;
|
||||
this.pairingReject = reject;
|
||||
this.pairingTimer = globalThis.setTimeout(() => {
|
||||
const error = new Error('Yak 引擎配对请求超过 5 秒未响应');
|
||||
this.failPairing(error);
|
||||
socket.close(1000, 'pairing timeout');
|
||||
}, HANDSHAKE_TIMEOUT);
|
||||
});
|
||||
socket.addEventListener('open', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
installationId: config.installationId,
|
||||
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
|
||||
nonce: clientNonce, publicKey: identity.publicKey,
|
||||
} satisfies BridgePairingEnvelope));
|
||||
});
|
||||
socket.addEventListener('message', (event) => void this.onPairingMessage(String(event.data)));
|
||||
socket.addEventListener('error', () => this.failPairing(new Error('无法连接本机 Yak 配对服务')));
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.pairingSocket === socket) this.pairingSocket = undefined;
|
||||
if (['requesting', 'pending'].includes(this.pairingStatus.state)) this.failPairing(new Error('Yak 配对连接已断开'));
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
cancelPairing(notify = true): BridgePairingStatus {
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, 'pairing cancelled');
|
||||
this.pairingSocket = undefined;
|
||||
const status: BridgePairingStatus = { state: 'idle', message: '配对已取消' };
|
||||
if (notify) this.setPairingStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
async unpair(): Promise<void> {
|
||||
const state = await getState();
|
||||
this.disconnect();
|
||||
this.cancelPairing(false);
|
||||
await clearBrowserBridgeIdentity(state.bridge.installationId);
|
||||
await updateState((current) => ({
|
||||
...current,
|
||||
bridge: {
|
||||
...current.bridge,
|
||||
pairedEngine: undefined,
|
||||
autoConnect: false,
|
||||
},
|
||||
}));
|
||||
this.setPairingStatus({ state: 'idle', message: '本地配对凭据已清除,浏览器安装身份保持不变' });
|
||||
}
|
||||
|
||||
private async onPairingMessage(raw: unknown): Promise<void> {
|
||||
let message: BridgePairingEnvelope;
|
||||
try {
|
||||
message = parseBridgePairingEnvelope(raw);
|
||||
} catch (error) {
|
||||
this.failPairing(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
const context = this.pairingContext;
|
||||
if (!context) return;
|
||||
if (message.type === 'pair_pending') {
|
||||
const code = await pairingVerificationCode({
|
||||
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
|
||||
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
|
||||
});
|
||||
if (code !== message.code) {
|
||||
this.failPairing(new Error('Yak 配对验证码校验失败'));
|
||||
this.pairingSocket?.close(1008, 'pairing transcript mismatch');
|
||||
return;
|
||||
}
|
||||
context.requestId = message.requestId;
|
||||
context.engineIdentityId = message.engineIdentityId;
|
||||
context.enginePublicKey = message.publicKey;
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
const status: BridgePairingStatus = {
|
||||
state: 'pending', message: '请在 Yakit 中确认相同的验证码',
|
||||
requestId: message.requestId, code, engineIdentityId: message.engineIdentityId, expiresAt: message.expiresAt,
|
||||
};
|
||||
this.setPairingStatus(status);
|
||||
this.pairingResolve?.(status);
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pair_approved') {
|
||||
if (!context.requestId || message.requestId !== context.requestId || !context.engineIdentityId || !context.enginePublicKey
|
||||
|| message.engineIdentityId !== context.engineIdentityId || !message.publicKey || !publicKeysEqual(message.publicKey, context.enginePublicKey)) {
|
||||
this.failPairing(new Error('Yak 配对批准信息与当前申请不一致'));
|
||||
return;
|
||||
}
|
||||
const next = await updateState((current) => ({
|
||||
...current,
|
||||
bridge: {
|
||||
...current.bridge,
|
||||
autoConnect: true,
|
||||
pairedEngine: {
|
||||
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
|
||||
publicKey: message.publicKey!, pairedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
this.setPairingStatus({ state: 'approved', message: '已与 Yak 引擎安全配对', engineIdentityId: message.engineIdentityId });
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, 'pairing approved');
|
||||
this.pairingSocket = undefined;
|
||||
await this.connect(next.bridge);
|
||||
return;
|
||||
}
|
||||
const state = message.type === 'pair_rejected' ? 'rejected' : message.type === 'pair_expired' ? 'expired' : 'error';
|
||||
const status: BridgePairingStatus = { state, message: message.message || 'Yak 引擎拒绝了配对申请', requestId: message.requestId };
|
||||
this.setPairingStatus(status);
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, state);
|
||||
this.pairingSocket = undefined;
|
||||
}
|
||||
|
||||
private failPairing(error: Error): void {
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
this.pairingReject?.(error);
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
this.pairingContext = undefined;
|
||||
this.setPairingStatus({ state: 'error', message: error.message });
|
||||
}
|
||||
|
||||
private setPairingStatus(status: BridgePairingStatus): void {
|
||||
this.pairingStatus = status;
|
||||
void browser.runtime.sendMessage({ action: PAIRING_STATUS_EVENT, payload: status }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const engineBridge = new EngineBridge();
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, Braces, Check, ChevronLeft, ChevronRight, Copy, ExternalLink, GripVertical,
|
||||
EyeOff, Network, Pause, Play, Radio, RefreshCw, Settings, ShieldCheck, X,
|
||||
} from 'lucide-react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
|
||||
import { READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
|
||||
import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
|
||||
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
|
||||
interface FloatingPanelProps {
|
||||
initialState: ExtensionState;
|
||||
initialTab?: ActiveTabInfo;
|
||||
initialBridge: BridgeStatus;
|
||||
yakIconUrl: string;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingPanel({ initialState, initialTab, initialBridge, yakIconUrl, embedded = false }: FloatingPanelProps) {
|
||||
const [state, setState] = useState(initialState);
|
||||
const [bridge, setBridge] = useState(initialBridge);
|
||||
const [tab] = useState(initialTab);
|
||||
const [expanded, setExpanded] = useState(embedded);
|
||||
const [side, setSide] = useState(initialState.floatingPanel.side);
|
||||
const [y, setY] = useState(initialState.floatingPanel.y);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [context, setContext] = useState<PageContext>();
|
||||
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
|
||||
const drag = useRef<{ pointerId: number; startX: number; startY: number; moved: boolean } | undefined>(undefined);
|
||||
|
||||
const activeProfile = useMemo(
|
||||
() => state.proxyProfiles.find((profile) => profile.id === state.activeProxyId),
|
||||
[state],
|
||||
);
|
||||
const grantActive = Boolean(
|
||||
state.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id),
|
||||
);
|
||||
const pendingHandoff = waitingHandoff(state.handoff);
|
||||
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
|
||||
const listener = (changes: Record<string, unknown>) => {
|
||||
if (isStateStorageChange(changes)) {
|
||||
void request('state.get').then((next) => {
|
||||
setState(next);
|
||||
setSide(next.floatingPanel.side);
|
||||
setY(next.floatingPanel.y);
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (AGENT_RUNTIME_STORAGE_KEY in changes) void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
|
||||
};
|
||||
browser.storage.onChanged.addListener(listener);
|
||||
return () => browser.storage.onChanged.removeListener(listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (message: unknown) => {
|
||||
const input = message as { action?: string; payload?: BridgeStatus };
|
||||
if (input?.action === 'bridge.status.changed' && input.payload) setBridge(input.payload);
|
||||
};
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, []);
|
||||
|
||||
// Embedded mode: report natural content height so the host shell can size the iframe (no dead space, internal scroll when clamped).
|
||||
useEffect(() => {
|
||||
if (!embedded) return undefined;
|
||||
const post = () => {
|
||||
const header = document.querySelector('.floating-panel__header');
|
||||
const body = document.querySelector('.floating-panel__body');
|
||||
const height = (header?.getBoundingClientRect().height || 46) + (body?.scrollHeight || 0);
|
||||
window.parent.postMessage({ channel: 'yakit-floating-host', type: 'resize', height: Math.ceil(height) }, '*');
|
||||
};
|
||||
post();
|
||||
const observer = new ResizeObserver(post);
|
||||
observer.observe(document.body);
|
||||
return () => observer.disconnect();
|
||||
}, [embedded]);
|
||||
|
||||
const run = async (task: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setNotice('');
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
setNotice(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openWorkspace = (section: string) => {
|
||||
const target = tab ? `?tabId=${tab.id}` : '';
|
||||
window.open(browser.runtime.getURL(`/options.html${target}#${section}`), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
const hideCurrentSite = () => run(async () => {
|
||||
if (!tab?.url) return;
|
||||
const origin = new URL(tab.url).origin;
|
||||
const current = state.floatingPanel;
|
||||
const siteOrigins = current.siteMode === 'allowlist'
|
||||
? current.siteOrigins.filter((item) => item !== origin)
|
||||
: [...new Set([...current.siteOrigins, origin])];
|
||||
setState(await request('panel.update', {
|
||||
siteMode: current.siteMode === 'allowlist' ? 'allowlist' : 'denylist', siteOrigins,
|
||||
}));
|
||||
});
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const current = drag.current;
|
||||
if (!current || current.pointerId !== event.pointerId) return;
|
||||
if (Math.hypot(event.clientX - current.startX, event.clientY - current.startY) > 4) current.moved = true;
|
||||
if (!current.moved) return;
|
||||
setY(Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92));
|
||||
setSide(event.clientX < window.innerWidth / 2 ? 'left' : 'right');
|
||||
};
|
||||
|
||||
const onPointerUp = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const current = drag.current;
|
||||
if (!current || current.pointerId !== event.pointerId) return;
|
||||
drag.current = undefined;
|
||||
if (current.moved) {
|
||||
const nextSide = event.clientX < window.innerWidth / 2 ? 'left' : 'right';
|
||||
const nextY = Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92);
|
||||
setSide(nextSide);
|
||||
setY(nextY);
|
||||
void request('panel.update', { side: nextSide, y: nextY }).then(setState).catch(() => undefined);
|
||||
} else {
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state.floatingPanel.enabled) return null;
|
||||
|
||||
const collapseEmbedded = () => window.parent.postMessage({ channel: 'yakit-floating-host', type: 'collapse' }, '*');
|
||||
|
||||
return (
|
||||
<div className={`floating-panel floating-panel--${side} ${embedded ? 'floating-panel--embedded' : ''} ${expanded ? 'is-expanded' : ''}`} style={embedded ? undefined : { top: `${y * 100}%` }}>
|
||||
<div className="floating-panel__header" onClick={embedded ? collapseEmbedded : undefined} onPointerDown={embedded ? undefined : onPointerDown} onPointerMove={embedded ? undefined : onPointerMove} onPointerUp={embedded ? undefined : onPointerUp}>
|
||||
<button className="floating-panel__brand" aria-label={expanded ? '收起 Yakit Browser Agent' : '展开 Yakit Browser Agent'}>
|
||||
<img src={yakIconUrl} alt="Yak" draggable={false} />
|
||||
<span className={`floating-panel__signal ${bridge.state}`} />
|
||||
</button>
|
||||
{expanded && <>
|
||||
<div className="floating-panel__title">
|
||||
<strong>Yakit Browser Agent</strong>
|
||||
<span>{activeProfile?.name || (state.activeProxyId === 'rules' ? '按规则分流' : '浏览器工具')}</span>
|
||||
</div>
|
||||
<GripVertical className="floating-panel__grip" size={15} aria-hidden="true" />
|
||||
{side === 'right' ? <ChevronRight size={15} /> : <ChevronLeft size={15} />}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="floating-panel__body">
|
||||
<Tabs key={handoff?.id || 'default'} defaultValue={handoff ? 'agent' : 'proxy'}>
|
||||
<TabsList className="floating-tabs">
|
||||
<TabsTrigger value="proxy"><Network size={13} />代理</TabsTrigger>
|
||||
<TabsTrigger value="context"><Braces size={13} />上下文</TabsTrigger>
|
||||
<TabsTrigger value="agent">{handoff ? <AlertTriangle size={13} /> : <ShieldCheck size={13} />}Agent</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proxy" className="floating-tab-content">
|
||||
<div className="floating-section-heading"><span>快速切换</span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
|
||||
<div className="floating-option-list">
|
||||
{state.proxyProfiles.map((profile) => (
|
||||
<button key={profile.id} className={state.activeProxyId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
|
||||
<i className="floating-radio" />
|
||||
<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>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="context" className="floating-tab-content">
|
||||
<div className="floating-page-meta"><strong title={tab?.title}>{tab?.title || '当前页面不可访问'}</strong><span title={tab?.url}>{tab?.url || '仅支持 HTTP(S) 页面'}</span></div>
|
||||
<Button variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void run(async () => setContext(await request('context.capture', { includeDom: true, includeStorage: true, includeCookies: true, tabId: tab?.id })))}>
|
||||
{busy ? <RefreshCw className="spin" size={14} /> : <Radio size={14} />}采集页面环境
|
||||
</Button>
|
||||
{context && <div className="floating-result"><span>{context.document?.forms.length || 0} 个表单 · {context.document?.interactive.length || 0} 个交互元素</span><Button size="icon" variant="ghost" title="复制上下文 JSON" onClick={() => void navigator.clipboard.writeText(JSON.stringify(context, null, 2))}><Copy size={14} /></Button></div>}
|
||||
<Button variant="ghost" onClick={() => openWorkspace('context')}>打开上下文工作台<ExternalLink size={14} /></Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agent" className="floating-tab-content">
|
||||
<div className="floating-status-row"><span className={`floating-dot ${bridge.state}`} /><span><strong>{bridge.state === 'connected' ? 'Yak 引擎在线' : 'Yak 引擎离线'}</strong><small>{bridge.message}</small></span><Button size="sm" variant="ghost" disabled={busy} onClick={() => void run(async () => { if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect'); setBridge(await request('bridge.status')); })}>{bridge.state === 'connected' ? '断开' : '连接'}</Button></div>
|
||||
{handoff ? <div className="floating-handoff" aria-live="assertive">
|
||||
<div className="floating-handoff__copy"><AlertTriangle size={16} /><span><strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong><small>{handoff.message}</small></span></div>
|
||||
<div className="floating-handoff__actions">
|
||||
<Button variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} />已完成</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={14} />取消</Button>
|
||||
</div>
|
||||
</div> : <>
|
||||
{grantActive && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '共享会话活动'}</strong><small title={state.activeGrant?.taskId}>{state.activeGrant?.taskId} · {state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? '控制权限' : '只读权限'}</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
|
||||
<label className="floating-share-row"><span><strong>共享当前主 frame</strong><small>30 分钟只读授权,可随时撤销</small></span><Switch checked={grantActive} disabled={!tab || busy} onCheckedChange={(checked) => void run(async () => setState(checked ? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 }) : await request('grant.revoke')))} /></label>
|
||||
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理控制授权<Settings size={14} /></Button>
|
||||
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
|
||||
</>}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{notice && <div className="floating-notice">{notice}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import {
|
||||
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
|
||||
redactNetworkRequests, startNetworkCapture, stopNetworkCapture,
|
||||
stopNetworkCapturesForGrant,
|
||||
} from '@/features/network-capture/service';
|
||||
import {
|
||||
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
|
||||
stopPageObservation, stopPageObservationsForGrant,
|
||||
} from '@/features/page-observation/service';
|
||||
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
|
||||
import type {
|
||||
BridgeGrant, BrowserRequestAnalysisBundle, BrowserTarget, CapabilityScope, HandoffReason,
|
||||
PageContextOptions, YakPocGenerateResult,
|
||||
} from '@/types/models';
|
||||
import { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
import { parseCapabilityParams } from '@/protocol/bridge';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { activateTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
|
||||
import {
|
||||
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
|
||||
} from '@/features/page-context/service';
|
||||
import { listCookies } from '@/features/cookies/service';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import { switchProxy } from '@/features/proxy/service';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
|
||||
|
||||
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
|
||||
|
||||
const CAPABILITY_SCOPES: Record<string, CapabilityScope> = {
|
||||
'browser.tabs': 'browser.tabs.read',
|
||||
'browser.frames': 'browser.tabs.read',
|
||||
'browser.context': 'browser.dom.read',
|
||||
'browser.node.inspect': 'browser.dom.read',
|
||||
'browser.node.action': 'browser.dom.write',
|
||||
'browser.cookies': 'browser.cookies.read',
|
||||
'browser.takeover': 'browser.tab.activate',
|
||||
'browser.handoff.request': 'browser.human.takeover',
|
||||
'browser.handoff.status': 'browser.human.takeover',
|
||||
'browser.network.start': 'browser.network.capture',
|
||||
'browser.network.status': 'browser.network.read',
|
||||
'browser.network.list': 'browser.network.read',
|
||||
'browser.network.clear': 'browser.network.capture',
|
||||
'browser.network.stop': 'browser.network.capture',
|
||||
'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.invoke': 'browser.page.invoke',
|
||||
'browser.eval': 'browser.page.eval.expression',
|
||||
'proxy.list': 'browser.proxy.read',
|
||||
'proxy.switch': 'browser.proxy.write',
|
||||
};
|
||||
|
||||
async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
|
||||
const state = await getState();
|
||||
const grant = state.activeGrant;
|
||||
if (!grant || grant.expiresAt <= Date.now()) {
|
||||
if (grant) {
|
||||
const state = await updateState((current) => ({
|
||||
...current,
|
||||
activeGrant: undefined,
|
||||
handoff: current.handoff?.state === 'waiting_for_user'
|
||||
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
|
||||
: current.handoff,
|
||||
}));
|
||||
await Promise.all([
|
||||
stopNetworkCapturesForGrant(grant.id),
|
||||
stopPageObservationsForGrant(grant.id),
|
||||
]);
|
||||
await setAgentRuntimeState('expired', grant);
|
||||
if (state.handoff) await browser.action.setBadgeText({ text: '', tabId: state.handoff.target.tabId });
|
||||
}
|
||||
throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
|
||||
}
|
||||
if (!grant.scopes.includes(required)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${required}`);
|
||||
return grant;
|
||||
}
|
||||
|
||||
function originOf(url: string): string {
|
||||
try {
|
||||
const origin = new URL(url).origin;
|
||||
return origin === 'null' ? '' : origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function allowedTarget(grant: BridgeGrant, input: Record<string, unknown>): 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);
|
||||
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
const currentFrame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
|
||||
if (!currentFrame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在');
|
||||
let currentOrigin = originOf(currentFrame.url);
|
||||
if (!currentOrigin) {
|
||||
currentOrigin = (await getFrameInventory(target.tabId)).find((frame) => frame.frameId === target.frameId)?.origin || '';
|
||||
}
|
||||
if (currentOrigin !== target.origin) throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
|
||||
if (target.documentId && currentFrame.documentId && target.documentId !== currentFrame.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
|
||||
}
|
||||
if (typeof input.documentId === 'string' && target.documentId && input.documentId !== target.documentId) {
|
||||
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
|
||||
}
|
||||
const resolved = await resolveDocumentTarget(target);
|
||||
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
|
||||
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
|
||||
if (!grant.scopes.includes(scope)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
|
||||
}
|
||||
|
||||
export async function routeCapability(
|
||||
method: string,
|
||||
params: unknown,
|
||||
requestEngine?: <T>(method: string, params: unknown) => Promise<T>,
|
||||
): Promise<unknown> {
|
||||
if (method === 'system.ping') return { now: Date.now(), extensionVersion: browser.runtime.getManifest().version };
|
||||
if (import.meta.env.FIREFOX && import.meta.env.MODE === 'store' && ['browser.invoke', 'browser.eval'].includes(method)) {
|
||||
throw new ExtensionError('channel_unavailable', 'Firefox AMO 渠道不提供页面函数调用或通用 Eval');
|
||||
}
|
||||
const input = parseCapabilityParams(method, params);
|
||||
const required = method === 'browser.eval' && input.mode === 'program'
|
||||
? 'browser.page.eval.program'
|
||||
: CAPABILITY_SCOPES[method];
|
||||
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
const grant = await activeGrant(required);
|
||||
|
||||
if (method === 'browser.tabs') {
|
||||
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
|
||||
const tabs = await Promise.all(tabIds.map(async (tabId) => {
|
||||
const targets = grant.targets.filter((target) => target.tabId === tabId);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await allowedTarget(grant, { tabId, frameId: target.frameId, documentId: target.documentId });
|
||||
return getTab(tabId);
|
||||
} catch {
|
||||
// A tab remains visible while at least one explicitly granted frame is current.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}));
|
||||
return tabs.filter(Boolean);
|
||||
}
|
||||
|
||||
if (method === 'browser.frames') {
|
||||
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
|
||||
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
|
||||
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
|
||||
}
|
||||
return getFrameInventory(tabId);
|
||||
}
|
||||
|
||||
if (method === 'browser.handoff.status') {
|
||||
const handoff = (await getState()).handoff;
|
||||
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
|
||||
}
|
||||
|
||||
if (method === 'browser.handoff.request') {
|
||||
const resolvedTarget = await allowedTarget(grant, input);
|
||||
const grantTarget = grant.targets.find((target) => target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId);
|
||||
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
|
||||
const now = Date.now();
|
||||
const state = await updateState((current) => {
|
||||
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
|
||||
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
|
||||
}
|
||||
if (current.handoff?.state === 'waiting_for_user') {
|
||||
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
handoff: {
|
||||
id: crypto.randomUUID(),
|
||||
taskId: grant.taskId,
|
||||
target: grantTarget,
|
||||
reason: input.reason as HandoffReason,
|
||||
message: typeof input.message === 'string' ? input.message : '',
|
||||
state: 'waiting_for_user',
|
||||
requestedAt: now,
|
||||
},
|
||||
};
|
||||
});
|
||||
await activateTab(resolvedTarget.tabId);
|
||||
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
|
||||
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
|
||||
await setAgentRuntimeState('waiting_for_human', grant);
|
||||
return state.handoff;
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.network.')) {
|
||||
const target = await allowedTarget(grant, input);
|
||||
if (method === 'browser.network.start') {
|
||||
if (input.captureHeaders === true || input.captureBody === true) requireScope(grant, 'browser.network.sensitive.read');
|
||||
return startNetworkCapture(target, {
|
||||
captureHeaders: input.captureHeaders === true,
|
||||
captureBody: input.captureBody === true,
|
||||
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
|
||||
maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined,
|
||||
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
|
||||
}
|
||||
if (method === 'browser.network.status') return networkCaptureStatus(target);
|
||||
if (method === 'browser.network.list') {
|
||||
const records = await listNetworkRequests(target, typeof input.limit === 'number' ? input.limit : 100);
|
||||
return grant.scopes.includes('browser.network.sensitive.read') ? records : redactNetworkRequests(records);
|
||||
}
|
||||
if (method === 'browser.network.clear') return clearNetworkRequests(target);
|
||||
if (method === 'browser.network.stop') return stopNetworkCapture(target);
|
||||
if (method === 'browser.network.export') return exportNetworkRequest(target, String(input.id));
|
||||
if (method === 'browser.network.poc') {
|
||||
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
|
||||
return requestEngine<YakPocGenerateResult>('yakit.poc.generate', await capturedRequestEnginePayload(target, String(input.id), false));
|
||||
}
|
||||
if (method === 'browser.network.analysis') {
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (method.startsWith('browser.observe.')) {
|
||||
const target = await allowedTarget(grant, input);
|
||||
if (method === 'browser.observe.start') {
|
||||
if (input.captureValues === true) requireScope(grant, 'browser.observation.sensitive.read');
|
||||
return startPageObservation(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.observe.clear') return clearPageObservations(target);
|
||||
if (method === 'browser.observe.stop') return stopPageObservation(target);
|
||||
}
|
||||
|
||||
if (method === 'browser.context') {
|
||||
const options: PageContextOptions = {
|
||||
includeDom: input.includeDom !== false,
|
||||
includeStorage: input.includeStorage === true,
|
||||
includeCookies: input.includeCookies === true,
|
||||
};
|
||||
if (options.includeStorage) requireScope(grant, 'browser.storage.read');
|
||||
if (options.includeCookies) requireScope(grant, 'browser.cookies.read');
|
||||
return capturePageContext(options, await allowedTarget(grant, input));
|
||||
}
|
||||
if (method === 'browser.node.inspect') {
|
||||
const target = await allowedTarget(grant, input);
|
||||
return inspectPageNode(String(input.captureId), String(input.nodeId), target);
|
||||
}
|
||||
if (method === 'browser.node.action') {
|
||||
const target = await allowedTarget(grant, input);
|
||||
return actOnPageNode(
|
||||
String(input.captureId),
|
||||
String(input.nodeId),
|
||||
input.action as 'click' | 'focus' | 'scroll' | 'setValue',
|
||||
target,
|
||||
typeof input.value === 'string' ? input.value : undefined,
|
||||
);
|
||||
}
|
||||
if (method === 'browser.cookies') {
|
||||
const target = await allowedTarget(grant, input);
|
||||
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
|
||||
const grantTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
|
||||
const url = frame?.url && /^https?:/i.test(frame.url) ? frame.url : `${grantTarget?.origin || ''}/`;
|
||||
if (!/^https?:/i.test(url)) throw new ExtensionError('target_unavailable', '目标 frame 没有可读取 Cookie 的 HTTP 来源');
|
||||
return listCookies(url);
|
||||
}
|
||||
if (method === 'browser.takeover') {
|
||||
const target = await allowedTarget(grant, input);
|
||||
await activateTab(target.tabId);
|
||||
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
|
||||
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
|
||||
globalThis.setTimeout(() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }), 10_000);
|
||||
return { activated: true, target };
|
||||
}
|
||||
if (method === 'browser.invoke') {
|
||||
if (typeof input.path !== 'string') throw new Error('缺少页面函数路径');
|
||||
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
|
||||
return invokePageFunction(input.path, Array.isArray(input.args) ? input.args : [], await allowedTarget(grant, input), timeoutMs);
|
||||
}
|
||||
if (method === 'browser.eval') {
|
||||
if (typeof input.code !== 'string' || !input.code.trim()) throw new Error('缺少页面执行代码');
|
||||
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
|
||||
return evalInPage(input.code, input.mode as 'expression' | 'program', await allowedTarget(grant, input), timeoutMs);
|
||||
}
|
||||
const state = await getState();
|
||||
if (method === 'proxy.list') return state.proxyProfiles;
|
||||
if (method === 'proxy.switch') {
|
||||
if (typeof input.id !== 'string') throw new Error('缺少代理配置 ID');
|
||||
await switchProxy(input.id);
|
||||
return { activeProxyId: input.id };
|
||||
}
|
||||
throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AuditEvent, HandoffReason, HumanHandoff } from '@/types/models';
|
||||
|
||||
export const HANDOFF_REASON_LABELS: Record<HandoffReason, string> = {
|
||||
qr_code: '需要扫码',
|
||||
mfa: '需要二次验证',
|
||||
captcha: '需要完成验证码',
|
||||
device_confirmation: '需要设备确认',
|
||||
other: '需要人工操作',
|
||||
};
|
||||
|
||||
export const AUDIT_CATEGORY_LABELS: Record<AuditEvent['category'], string> = {
|
||||
grant: '授权',
|
||||
bridge: 'Bridge',
|
||||
capability: '能力调用',
|
||||
handoff: '人工接管',
|
||||
settings: '设置',
|
||||
};
|
||||
|
||||
export const AUDIT_OUTCOME_LABELS: Record<AuditEvent['outcome'], string> = {
|
||||
success: '成功',
|
||||
denied: '已拒绝',
|
||||
error: '错误',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
export function waitingHandoff(handoff?: HumanHandoff): HumanHandoff | undefined {
|
||||
return handoff?.state === 'waiting_for_user' ? handoff : undefined;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { vi, describe, expect, it } from 'vitest';
|
||||
|
||||
vi.mock('wxt/browser', () => ({ browser: { declarativeNetRequest: {} } }));
|
||||
|
||||
import { buildUserAgentDnrRules } from './user-agent';
|
||||
|
||||
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^');
|
||||
expect(rule.condition.resourceTypes).toContain('websocket');
|
||||
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set', value: 'Yakit-E2E/1.0' }] });
|
||||
});
|
||||
|
||||
it('ignores disabled rules', () => {
|
||||
expect(buildUserAgentDnrRules([{ id: 'x', name: 'X', enabled: false, userAgent: 'x', domains: [] }])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { UserAgentRule } from '@/types/models';
|
||||
|
||||
const RULE_ID_BASE = 20_000;
|
||||
const MAX_UA_RULES = 5_000;
|
||||
|
||||
function domainFilter(domain: string): string {
|
||||
const normalized = domain.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
|
||||
return normalized ? `||${normalized}^` : '*';
|
||||
}
|
||||
|
||||
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 async function applyUserAgentRules(rules: UserAgentRule[]): 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) });
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import { NETWORK_CAPTURE_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type {
|
||||
BrowserTarget, NetworkBody, NetworkCaptureOptions, NetworkCaptureStatus, NetworkHeader,
|
||||
NetworkRequestExport, NetworkRequestRecord,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
const DEFAULT_OPTIONS: NetworkCaptureOptions = {
|
||||
captureHeaders: false,
|
||||
captureBody: false,
|
||||
maxEntries: 100,
|
||||
maxBodyBytes: 32 * 1024,
|
||||
};
|
||||
const MAX_ENTRIES = 200;
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const MAX_HEADER_COUNT = 256;
|
||||
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
|
||||
const MAX_HEADER_BYTES = 64 * 1024;
|
||||
const MAX_SESSION_BYTES = 5 * 1024 * 1024;
|
||||
const CAPTURED_RESOURCE_TYPES = ['xmlhttprequest', 'ping', 'other', 'main_frame', 'sub_frame'] as const;
|
||||
|
||||
interface CaptureSession {
|
||||
target: BrowserTarget;
|
||||
startedAt: number;
|
||||
droppedCount: number;
|
||||
options: NetworkCaptureOptions;
|
||||
records: NetworkRequestRecord[];
|
||||
owner: { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number };
|
||||
}
|
||||
|
||||
interface SessionStorageArea {
|
||||
get(key: string): Promise<Record<string, unknown>>;
|
||||
set(items: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
const captureSessions = new Map<number, CaptureSession>();
|
||||
const sessionStorage = (browser.storage as unknown as { session?: SessionStorageArea }).session;
|
||||
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
let notifyTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
const pendingNotificationTabs = new Set<number>();
|
||||
|
||||
function normalizedOptions(input?: Partial<NetworkCaptureOptions>): NetworkCaptureOptions {
|
||||
return {
|
||||
captureHeaders: input?.captureHeaders === true,
|
||||
captureBody: input?.captureBody === true,
|
||||
maxEntries: Math.min(Math.max(input?.maxEntries || DEFAULT_OPTIONS.maxEntries, 10), MAX_ENTRIES),
|
||||
maxBodyBytes: Math.min(Math.max(input?.maxBodyBytes || DEFAULT_OPTIONS.maxBodyBytes, 1024), MAX_BODY_BYTES),
|
||||
};
|
||||
}
|
||||
|
||||
function isCaptureSession(value: unknown): value is CaptureSession {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const session = value as Partial<CaptureSession>;
|
||||
return Boolean(
|
||||
session.target && Number.isSafeInteger(session.target.tabId) && Number.isSafeInteger(session.target.frameId)
|
||||
&& typeof session.startedAt === 'number' && Array.isArray(session.records),
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSessions(): Promise<void> {
|
||||
if (!sessionStorage) return;
|
||||
try {
|
||||
const stored = await sessionStorage.get(NETWORK_CAPTURE_STORAGE_KEY);
|
||||
const sessions = stored[NETWORK_CAPTURE_STORAGE_KEY];
|
||||
if (!Array.isArray(sessions)) return;
|
||||
for (const value of sessions) {
|
||||
if (!isCaptureSession(value)) continue;
|
||||
const session: CaptureSession = {
|
||||
...value,
|
||||
droppedCount: Number.isSafeInteger(value.droppedCount) ? value.droppedCount : 0,
|
||||
options: normalizedOptions(value.options),
|
||||
records: value.records.slice(-MAX_ENTRIES),
|
||||
owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'
|
||||
? value.owner
|
||||
: { kind: 'local' },
|
||||
};
|
||||
captureSessions.set(session.target.tabId, session);
|
||||
}
|
||||
} catch {
|
||||
// Session persistence is an optimization; capture still works in memory.
|
||||
}
|
||||
}
|
||||
|
||||
const restorePromise = restoreSessions();
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (!sessionStorage || persistTimer) return;
|
||||
persistTimer = globalThis.setTimeout(() => {
|
||||
persistTimer = undefined;
|
||||
void sessionStorage.set({ [NETWORK_CAPTURE_STORAGE_KEY]: [...captureSessions.values()] }).catch(() => undefined);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function notifyChanged(tabId: number): void {
|
||||
pendingNotificationTabs.add(tabId);
|
||||
if (notifyTimer) return;
|
||||
notifyTimer = globalThis.setTimeout(() => {
|
||||
notifyTimer = undefined;
|
||||
const tabIds = [...pendingNotificationTabs];
|
||||
pendingNotificationTabs.clear();
|
||||
for (const changedTabId of tabIds) {
|
||||
void browser.runtime.sendMessage({ action: 'network.capture.changed', payload: { tabId: changedTabId } }).catch(() => undefined);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function matchingSession(details: Pick<Browser.webRequest.WebRequestDetails, 'tabId' | 'frameId' | 'type'> & { documentId?: string }): CaptureSession | undefined {
|
||||
const session = captureSessions.get(details.tabId);
|
||||
if (!session || details.frameId !== session.target.frameId) return undefined;
|
||||
if (session.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
|
||||
captureSessions.delete(details.tabId);
|
||||
schedulePersist();
|
||||
notifyChanged(details.tabId);
|
||||
return undefined;
|
||||
}
|
||||
const isFrameNavigation = details.type === 'main_frame' || details.type === 'sub_frame';
|
||||
if (!isFrameNavigation && session.target.documentId && details.documentId && session.target.documentId !== details.documentId) return undefined;
|
||||
return session;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeBody(bytes: Uint8Array, byteLength: number, truncated: boolean): NetworkBody {
|
||||
try {
|
||||
return { encoding: 'utf8', data: new TextDecoder('utf-8', { fatal: true }).decode(bytes), byteLength, truncated };
|
||||
} catch {
|
||||
return { encoding: 'base64', data: bytesToBase64(bytes), byteLength, truncated };
|
||||
}
|
||||
}
|
||||
|
||||
function requestBody(details: Browser.webRequest.OnBeforeRequestDetails, maxBytes: number): NetworkBody | undefined {
|
||||
const raw = details.requestBody?.raw || [];
|
||||
if (raw.length > 0) {
|
||||
const parts = raw.flatMap((part) => part.bytes ? [new Uint8Array(part.bytes)] : []);
|
||||
const byteLength = parts.reduce((total, part) => total + part.byteLength, 0);
|
||||
const output = new Uint8Array(Math.min(byteLength, maxBytes));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
if (offset >= output.length) break;
|
||||
const slice = part.subarray(0, output.length - offset);
|
||||
output.set(slice, offset);
|
||||
offset += slice.length;
|
||||
}
|
||||
const body = encodeBody(output, byteLength, byteLength > output.length);
|
||||
if (parts.length !== raw.length) body.reconstructed = true;
|
||||
return body;
|
||||
}
|
||||
const formData = details.requestBody?.formData;
|
||||
if (!formData) return undefined;
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, values] of Object.entries(formData)) {
|
||||
for (const value of values) params.append(key, typeof value === 'string' ? value : '[binary]');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(params.toString());
|
||||
return { ...encodeBody(bytes.subarray(0, maxBytes), bytes.byteLength, bytes.byteLength > maxBytes), reconstructed: true };
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: Browser.webRequest.HttpHeader[]): NetworkHeader[] | undefined {
|
||||
if (!headers) return undefined;
|
||||
const output: NetworkHeader[] = [];
|
||||
let remaining = MAX_HEADER_BYTES;
|
||||
for (const header of headers.slice(0, MAX_HEADER_COUNT)) {
|
||||
const name = header.name.slice(0, 256);
|
||||
const sourceValue = header.value || (header.binaryValue ? `[binary:${header.binaryValue.byteLength}]` : '');
|
||||
const value = sourceValue.slice(0, Math.min(MAX_HEADER_VALUE_LENGTH, Math.max(remaining - name.length, 0)));
|
||||
if (remaining <= name.length) break;
|
||||
output.push({ name, value });
|
||||
remaining -= name.length + value.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function findRecord(session: CaptureSession, requestId: string): NetworkRequestRecord | undefined {
|
||||
for (let index = session.records.length - 1; index >= 0; index -= 1) {
|
||||
if (session.records[index].requestId === requestId) return session.records[index];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function commit(session: CaptureSession, tabId: number): void {
|
||||
while (session.records.length > session.options.maxEntries) {
|
||||
session.records.shift();
|
||||
session.droppedCount += 1;
|
||||
}
|
||||
let estimatedBytes = session.records.reduce((total, record) => total + JSON.stringify(record).length, 0);
|
||||
while (estimatedBytes > MAX_SESSION_BYTES && session.records.length > 1) {
|
||||
const removed = session.records.shift();
|
||||
estimatedBytes -= removed ? JSON.stringify(removed).length : 0;
|
||||
session.droppedCount += 1;
|
||||
}
|
||||
schedulePersist();
|
||||
notifyChanged(tabId);
|
||||
}
|
||||
|
||||
function onBeforeRequest(details: Browser.webRequest.OnBeforeRequestDetails): Browser.webRequest.BlockingResponse | undefined {
|
||||
const session = matchingSession(details);
|
||||
if (!session) return undefined;
|
||||
let record = findRecord(session, details.requestId);
|
||||
if (!record) {
|
||||
record = {
|
||||
id: crypto.randomUUID(), requestId: details.requestId, tabId: details.tabId, frameId: details.frameId,
|
||||
documentId: details.documentId, url: details.url, method: details.method, resourceType: details.type,
|
||||
initiator: details.initiator, startedAt: details.timeStamp, requestHeadersCaptured: session.options.captureHeaders,
|
||||
requestBodyCaptured: session.options.captureBody, redirects: [],
|
||||
};
|
||||
session.records.push(record);
|
||||
} else {
|
||||
record.url = details.url;
|
||||
record.method = details.method;
|
||||
record.startedAt = details.timeStamp;
|
||||
}
|
||||
if (session.options.captureBody) record.requestBody = requestBody(details, session.options.maxBodyBytes);
|
||||
commit(session, details.tabId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function onBeforeSendHeaders(details: Browser.webRequest.OnBeforeSendHeadersDetails): Browser.webRequest.BlockingResponse | undefined {
|
||||
const session = matchingSession(details);
|
||||
if (!session?.options.captureHeaders) return undefined;
|
||||
const record = findRecord(session, details.requestId);
|
||||
if (!record) return undefined;
|
||||
record.requestHeaders = normalizeHeaders(details.requestHeaders);
|
||||
commit(session, details.tabId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function onBeforeRedirect(details: Browser.webRequest.OnBeforeRedirectDetails): void {
|
||||
const session = matchingSession(details);
|
||||
if (!session) return;
|
||||
const record = findRecord(session, details.requestId);
|
||||
if (!record) return;
|
||||
record.redirects.push({ url: details.url, statusCode: details.statusCode, redirectUrl: details.redirectUrl, timestamp: details.timeStamp });
|
||||
record.statusCode = details.statusCode;
|
||||
record.statusLine = details.statusLine;
|
||||
commit(session, details.tabId);
|
||||
}
|
||||
|
||||
function completeRecord(details: Browser.webRequest.OnCompletedDetails): void {
|
||||
const session = matchingSession(details);
|
||||
if (!session) return;
|
||||
const record = findRecord(session, details.requestId);
|
||||
if (!record) return;
|
||||
record.completedAt = details.timeStamp;
|
||||
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
|
||||
record.statusCode = details.statusCode;
|
||||
record.statusLine = details.statusLine;
|
||||
record.fromCache = details.fromCache;
|
||||
record.ip = details.ip;
|
||||
if (session.options.captureHeaders) record.responseHeaders = normalizeHeaders(details.responseHeaders);
|
||||
const contentType = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-type')?.value;
|
||||
const contentLength = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-length')?.value;
|
||||
record.responseContentType = contentType?.slice(0, 512);
|
||||
if (contentLength && Number.isSafeInteger(Number(contentLength))) record.responseSize = Number(contentLength);
|
||||
commit(session, details.tabId);
|
||||
}
|
||||
|
||||
function errorRecord(details: Browser.webRequest.OnErrorOccurredDetails): void {
|
||||
const session = matchingSession(details);
|
||||
if (!session) return;
|
||||
const record = findRecord(session, details.requestId);
|
||||
if (!record) return;
|
||||
record.completedAt = details.timeStamp;
|
||||
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
|
||||
record.error = details.error.slice(0, 512);
|
||||
commit(session, details.tabId);
|
||||
}
|
||||
|
||||
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestBody']);
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestHeaders', 'extraHeaders']);
|
||||
browser.webRequest.onBeforeRedirect.addListener(onBeforeRedirect, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
|
||||
browser.webRequest.onCompleted.addListener(completeRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
|
||||
browser.webRequest.onErrorOccurred.addListener(errorRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] });
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
if (captureSessions.delete(tabId)) schedulePersist();
|
||||
});
|
||||
|
||||
function sameTarget(left: BrowserTarget, right: BrowserTarget): boolean {
|
||||
return left.tabId === right.tabId && left.frameId === right.frameId
|
||||
&& (!left.documentId || !right.documentId || left.documentId === right.documentId);
|
||||
}
|
||||
|
||||
function sessionFor(target: BrowserTarget): CaptureSession | undefined {
|
||||
const session = captureSessions.get(target.tabId);
|
||||
if (session?.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
|
||||
captureSessions.delete(target.tabId);
|
||||
schedulePersist();
|
||||
return undefined;
|
||||
}
|
||||
return session && sameTarget(session.target, target) ? session : undefined;
|
||||
}
|
||||
|
||||
export async function startNetworkCapture(
|
||||
target: BrowserTarget,
|
||||
options?: Partial<NetworkCaptureOptions>,
|
||||
owner: CaptureSession['owner'] = { kind: 'local' },
|
||||
): Promise<NetworkCaptureStatus> {
|
||||
await restorePromise;
|
||||
const session: CaptureSession = { target, startedAt: Date.now(), droppedCount: 0, options: normalizedOptions(options), records: [], owner };
|
||||
captureSessions.set(target.tabId, session);
|
||||
schedulePersist();
|
||||
notifyChanged(target.tabId);
|
||||
return networkCaptureStatus(target);
|
||||
}
|
||||
|
||||
export async function networkCaptureStatus(target: BrowserTarget): Promise<NetworkCaptureStatus> {
|
||||
await restorePromise;
|
||||
const session = sessionFor(target);
|
||||
return session
|
||||
? { active: true, target: session.target, startedAt: session.startedAt, count: session.records.length, droppedCount: session.droppedCount, options: session.options }
|
||||
: { active: false, target, count: 0, droppedCount: 0 };
|
||||
}
|
||||
|
||||
export async function listNetworkRequests(target: BrowserTarget, limit = 100): Promise<NetworkRequestRecord[]> {
|
||||
await restorePromise;
|
||||
const session = sessionFor(target);
|
||||
if (!session) return [];
|
||||
return structuredClone(session.records.slice(-Math.min(Math.max(limit, 1), MAX_ENTRIES)).reverse());
|
||||
}
|
||||
|
||||
export async function clearNetworkRequests(target: BrowserTarget): Promise<NetworkCaptureStatus> {
|
||||
await restorePromise;
|
||||
const session = sessionFor(target);
|
||||
if (session) {
|
||||
session.records = [];
|
||||
session.droppedCount = 0;
|
||||
schedulePersist();
|
||||
notifyChanged(target.tabId);
|
||||
}
|
||||
return networkCaptureStatus(target);
|
||||
}
|
||||
|
||||
export async function stopNetworkCapture(target: BrowserTarget): Promise<NetworkCaptureStatus> {
|
||||
await restorePromise;
|
||||
captureSessions.delete(target.tabId);
|
||||
schedulePersist();
|
||||
notifyChanged(target.tabId);
|
||||
return { active: false, target, count: 0, droppedCount: 0 };
|
||||
}
|
||||
|
||||
export async function stopNetworkCapturesForGrant(grantId: string): Promise<void> {
|
||||
await restorePromise;
|
||||
let changed = false;
|
||||
for (const [tabId, session] of captureSessions) {
|
||||
if (session.owner.kind !== 'grant' || session.owner.grantId !== grantId) continue;
|
||||
captureSessions.delete(tabId);
|
||||
notifyChanged(tabId);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) schedulePersist();
|
||||
}
|
||||
|
||||
function bodyBytes(body?: NetworkBody): Uint8Array {
|
||||
if (!body) return new Uint8Array();
|
||||
return body.encoding === 'base64' ? base64ToBytes(body.data) : new TextEncoder().encode(body.data);
|
||||
}
|
||||
|
||||
export async function exportNetworkRequest(target: BrowserTarget, id: string): Promise<NetworkRequestExport> {
|
||||
await restorePromise;
|
||||
const session = sessionFor(target);
|
||||
const record = session?.records.find((item) => item.id === id);
|
||||
if (!record) throw new ExtensionError('network_request_not_found', '网络请求不存在或已经被有界缓冲区淘汰');
|
||||
if (!record.requestHeadersCaptured || !record.requestHeaders) {
|
||||
throw new ExtensionError('network_headers_not_captured', '该请求未捕获实际请求头,无法生成可重放数据包');
|
||||
}
|
||||
const url = new URL(record.url);
|
||||
const headers = record.requestHeaders.filter((header) => !header.name.startsWith(':'));
|
||||
if (!headers.some((header) => header.name.toLowerCase() === 'host')) {
|
||||
headers.unshift({ name: 'Host', value: url.host });
|
||||
}
|
||||
const path = `${url.pathname || '/'}${url.search}`;
|
||||
const head = `${record.method} ${path} HTTP/1.1\r\n${headers.map((header) => `${header.name}: ${header.value}`).join('\r\n')}\r\n\r\n`;
|
||||
const headBytes = new TextEncoder().encode(head);
|
||||
const body = bodyBytes(record.requestBody);
|
||||
const packet = new Uint8Array(headBytes.length + body.length);
|
||||
packet.set(headBytes);
|
||||
packet.set(body, headBytes.length);
|
||||
const limitations: string[] = [];
|
||||
if (record.requestBody?.truncated) limitations.push(`请求体只保留前 ${body.length} 字节`);
|
||||
if (record.requestBody?.reconstructed) limitations.push('浏览器未提供完整原始请求体,当前内容由可用字段重建');
|
||||
if (!record.requestBody && !['GET', 'HEAD', 'OPTIONS'].includes(record.method.toUpperCase())) {
|
||||
limitations.push(record.requestBodyCaptured ? '浏览器未提供该请求体,重放数据包可能不完整' : '捕获时未启用请求体,重放数据包可能不完整');
|
||||
}
|
||||
const rawRequest = record.requestBody?.encoding === 'base64'
|
||||
? `${head}[binary body: ${record.requestBody.byteLength} bytes]`
|
||||
: `${head}${record.requestBody?.data || ''}`;
|
||||
return { id: record.id, url: record.url, isHttps: url.protocol === 'https:', rawRequest, rawRequestBase64: bytesToBase64(packet), limitations };
|
||||
}
|
||||
|
||||
export function redactNetworkRequests(records: NetworkRequestRecord[]): NetworkRequestRecord[] {
|
||||
return records.map(({ requestHeaders: _requestHeaders, responseHeaders: _responseHeaders, requestBody: _requestBody, ...record }) => ({
|
||||
...record,
|
||||
requestHeadersCaptured: false,
|
||||
requestBodyCaptured: false,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { observationAnalysisWindow } from '@/features/page-observation/service';
|
||||
import type { BrowserTarget } from '@/types/models';
|
||||
import { exportNetworkRequest, listNetworkRequests } from './service';
|
||||
|
||||
export async function capturedRequestEnginePayload(target: BrowserTarget, id: string, includeObservations: boolean) {
|
||||
const [exported, records] = await Promise.all([
|
||||
exportNetworkRequest(target, id),
|
||||
listNetworkRequests(target, 200),
|
||||
]);
|
||||
const record = records.find((item) => item.id === id);
|
||||
return {
|
||||
rawRequestBase64: exported.rawRequestBase64,
|
||||
isHttps: exported.isHttps,
|
||||
observations: includeObservations && record
|
||||
? await observationAnalysisWindow(target, record.startedAt)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import type { ContentScriptContext } from 'wxt/utils/content-script-context';
|
||||
import { createOpaqueId } from '@/shared/id';
|
||||
import {
|
||||
PAGE_BRIDGE_CHANNEL,
|
||||
PAGE_REQUEST_EVENT,
|
||||
PAGE_RESPONSE_EVENT,
|
||||
type PageBridgeRequest,
|
||||
type PageBridgeResponse,
|
||||
type PageOperation,
|
||||
} from './protocol';
|
||||
|
||||
type InternalMessage = PageOperation & { channel: typeof PAGE_BRIDGE_CHANNEL; timeoutMs?: number };
|
||||
|
||||
export async function installPageWorldBridge(ctx: ContentScriptContext): Promise<void> {
|
||||
const pending = new Map<string, {
|
||||
resolve: (response: PageBridgeResponse) => void;
|
||||
timer: ReturnType<typeof globalThis.setTimeout>;
|
||||
}>();
|
||||
|
||||
const { script } = await injectScript('/page-main-world.js', {
|
||||
keepInDom: true,
|
||||
modifyScript(element) {
|
||||
element.id = createOpaqueId('yakit-page-bridge');
|
||||
},
|
||||
});
|
||||
|
||||
const onResponse = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || typeof event.detail !== 'string') return;
|
||||
let response: PageBridgeResponse;
|
||||
try {
|
||||
response = JSON.parse(event.detail) as PageBridgeResponse;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const task = pending.get(response.id);
|
||||
if (!task) return;
|
||||
globalThis.clearTimeout(task.timer);
|
||||
pending.delete(response.id);
|
||||
task.resolve(response);
|
||||
};
|
||||
script.addEventListener(PAGE_RESPONSE_EVENT, onResponse);
|
||||
ctx.onInvalidated(() => {
|
||||
script.removeEventListener(PAGE_RESPONSE_EVENT, onResponse);
|
||||
script.remove();
|
||||
for (const task of pending.values()) globalThis.clearTimeout(task.timer);
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
const execute = (message: InternalMessage): Promise<PageBridgeResponse> => {
|
||||
const id = createOpaqueId('page-request');
|
||||
const timeoutMs = Math.min(Math.max(message.timeoutMs || 10_000, 250), 60_000);
|
||||
const request: PageBridgeRequest = message.operation === 'eval'
|
||||
? { id, timeoutMs, operation: 'eval', mode: message.mode, code: message.code }
|
||||
: { id, timeoutMs, operation: 'invoke', path: message.path, args: message.args };
|
||||
return new Promise((resolve) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
pending.delete(id);
|
||||
resolve({ id, ok: false, error: { name: 'TimeoutError', message: `页面执行超过 ${timeoutMs}ms` } });
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, timer });
|
||||
script.dispatchEvent(new CustomEvent(PAGE_REQUEST_EVENT, { detail: JSON.stringify(request) }));
|
||||
});
|
||||
};
|
||||
|
||||
const onMessage = (message: unknown, _sender: Browser.runtime.MessageSender, sendResponse: (response: PageBridgeResponse) => void) => {
|
||||
const input = message as InternalMessage;
|
||||
if (input?.channel !== PAGE_BRIDGE_CHANNEL || !['eval', 'invoke'].includes(input.operation)) return undefined;
|
||||
void execute(input).then(sendResponse);
|
||||
return true;
|
||||
};
|
||||
browser.runtime.onMessage.addListener(onMessage);
|
||||
ctx.onInvalidated(() => browser.runtime.onMessage.removeListener(onMessage));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { vi, describe, expect, it } from 'vitest';
|
||||
|
||||
vi.mock('wxt/browser', () => ({ browser: {} }));
|
||||
Object.assign(globalThis, { Node: class Node {}, Element: class Element {} });
|
||||
|
||||
import { executeInUserScriptWorld } from './execution-adapter';
|
||||
|
||||
describe('page execution serializer', () => {
|
||||
it('serializes BigInt and circular values without throwing', async () => {
|
||||
const response = await executeInUserScriptWorld({
|
||||
operation: 'eval', mode: 'expression',
|
||||
code: '(() => { const value = { big: 42n }; value.self = value; return value; })()', timeoutMs: 500,
|
||||
}, () => { const value: Record<string, unknown> = { big: 42n }; value.self = value; return value; });
|
||||
expect(response.ok).toBe(true);
|
||||
if (response.ok) {
|
||||
expect(response.result.value).toMatchObject({ big: { $type: 'bigint', value: '42' }, self: { $type: 'circular' } });
|
||||
expect(response.result.truncated).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('distinguishes expression and program syntax', async () => {
|
||||
const expression = await executeInUserScriptWorld({ operation: 'eval', mode: 'expression', code: '1 + 1', timeoutMs: 500 }, () => 1 + 1);
|
||||
const program = await executeInUserScriptWorld({ operation: 'eval', mode: 'program', code: 'const answer = 40; answer + 2', timeoutMs: 500 }, () => { const answer = 40; return answer + 2; });
|
||||
expect(expression.ok && expression.result.value).toBe(2);
|
||||
expect(program.ok && program.result.value).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BrowserTarget, PageEvalResult } from '@/types/models';
|
||||
import { PAGE_BRIDGE_CHANNEL, type PageBridgeResponse, type PageOperation } from './protocol';
|
||||
|
||||
export type PageExecutionMode = 'user-scripts' | 'injected-bridge' | 'invoke-only';
|
||||
|
||||
interface PageExecutionAdapter {
|
||||
readonly mode: PageExecutionMode;
|
||||
execute(target: BrowserTarget, operation: PageOperation, timeoutMs: number): Promise<PageEvalResult>;
|
||||
}
|
||||
|
||||
interface UserScriptInjectionResult {
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UserScriptsApi {
|
||||
getScripts(): Promise<unknown[]>;
|
||||
execute(injection: {
|
||||
target: { tabId: number; frameIds?: number[]; documentIds?: string[] };
|
||||
js: Array<{ code: string }>;
|
||||
world: 'MAIN' | 'USER_SCRIPT';
|
||||
}): Promise<UserScriptInjectionResult[]>;
|
||||
}
|
||||
|
||||
type UserScriptExecutionResponse = {
|
||||
ok: true;
|
||||
result: PageEvalResult;
|
||||
} | {
|
||||
ok: false;
|
||||
error: { name: string; message: string; stack?: string };
|
||||
};
|
||||
|
||||
type PageEvaluation = () => unknown | Promise<unknown>;
|
||||
|
||||
function evaluationSource(operation: PageOperation): string {
|
||||
if (operation.operation !== 'eval') return 'undefined';
|
||||
if (operation.mode === 'expression') return `async () => (\n${operation.code}\n)`;
|
||||
return `async () => {\n${operation.code}\n}`;
|
||||
}
|
||||
|
||||
export async function executeInUserScriptWorld(
|
||||
input: PageOperation & { timeoutMs: number },
|
||||
evaluate?: PageEvaluation,
|
||||
): Promise<UserScriptExecutionResponse> {
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_ITEMS = 100;
|
||||
const MAX_STRING = 100_000;
|
||||
const startedAt = performance.now();
|
||||
|
||||
const serialize = (value: unknown): Omit<PageEvalResult, 'durationMs'> => {
|
||||
const seen = new WeakSet<object>();
|
||||
let truncated = false;
|
||||
const visit = (current: unknown, depth: number): unknown => {
|
||||
if (current === null) return null;
|
||||
if (typeof current === 'string') {
|
||||
if (current.length > MAX_STRING) truncated = true;
|
||||
return current.slice(0, MAX_STRING);
|
||||
}
|
||||
if (typeof current === 'number' || typeof current === 'boolean') return current;
|
||||
if (typeof current === 'undefined') return { $type: 'undefined' };
|
||||
if (typeof current === 'bigint') return { $type: 'bigint', value: current.toString() };
|
||||
if (typeof current === 'symbol') return { $type: 'symbol', value: String(current) };
|
||||
if (typeof current === 'function') {
|
||||
const source = Function.prototype.toString.call(current);
|
||||
if (source.length > 2_000) truncated = true;
|
||||
return { $type: 'function', name: current.name || '', source: source.slice(0, 2_000) };
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
truncated = true;
|
||||
return { $type: 'max-depth', constructor: (current as object).constructor?.name || 'Object' };
|
||||
}
|
||||
if (seen.has(current as object)) return { $type: 'circular' };
|
||||
seen.add(current as object);
|
||||
if (current instanceof Error) return { $type: 'error', name: current.name, message: current.message, stack: current.stack?.slice(0, 10_000) };
|
||||
if (current instanceof Date) return { $type: 'date', value: current.toISOString() };
|
||||
if (current instanceof RegExp) return { $type: 'regexp', value: String(current) };
|
||||
if (current instanceof Node) {
|
||||
const element = current instanceof Element ? current : current.parentElement;
|
||||
const html = element?.outerHTML || current.textContent || '';
|
||||
if (html.length > 10_000) truncated = true;
|
||||
return { $type: 'node', name: current.nodeName, html: html.slice(0, 10_000) };
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
if (current.length > MAX_ITEMS) truncated = true;
|
||||
return current.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1));
|
||||
}
|
||||
const output: Record<string, unknown> = {};
|
||||
const allKeys = Reflect.ownKeys(current as object);
|
||||
if (allKeys.length > MAX_ITEMS) truncated = true;
|
||||
for (const key of allKeys.slice(0, MAX_ITEMS)) {
|
||||
const name = typeof key === 'symbol' ? `[${String(key)}]` : key;
|
||||
try {
|
||||
output[name] = visit(Reflect.get(current as object, key), depth + 1);
|
||||
} catch (error) {
|
||||
output[name] = { $type: 'unreadable', message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const normalized = visit(value, 0);
|
||||
let preview: string;
|
||||
try {
|
||||
preview = typeof value === 'string' ? value : JSON.stringify(normalized);
|
||||
} catch {
|
||||
preview = String(value);
|
||||
}
|
||||
return {
|
||||
value: normalized,
|
||||
type: value === null ? 'null' : typeof value,
|
||||
preview: preview.slice(0, 2_000),
|
||||
truncated: truncated || preview.length > 2_000,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const operation = (async () => {
|
||||
if (input.operation === 'eval') {
|
||||
if (!evaluate) throw new Error('页面 Eval 缺少直接 User Script 执行体');
|
||||
return await evaluate();
|
||||
}
|
||||
const segments = input.path.split('.').filter(Boolean);
|
||||
let owner: unknown = window;
|
||||
let target: unknown = window;
|
||||
for (const segment of segments) {
|
||||
owner = target;
|
||||
target = Reflect.get(target as object, segment);
|
||||
}
|
||||
if (typeof target !== 'function') throw new TypeError(`${input.path} is not a function`);
|
||||
return await Reflect.apply(target, owner, input.args);
|
||||
})();
|
||||
let timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = globalThis.setTimeout(() => reject(new Error(`页面执行超过 ${input.timeoutMs}ms`)), input.timeoutMs);
|
||||
});
|
||||
const serialized = serialize(await Promise.race([operation, timeout]).finally(() => {
|
||||
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
|
||||
}));
|
||||
return { ok: true, result: { ...serialized, durationMs: Math.round((performance.now() - startedAt) * 100) / 100 } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: error instanceof Error ? error.name : 'Error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack?.slice(0, 10_000) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const injectedBridgeAdapter: PageExecutionAdapter = {
|
||||
mode: 'injected-bridge',
|
||||
async execute(target, operation, timeoutMs) {
|
||||
const response = await browser.tabs.sendMessage(target.tabId, {
|
||||
channel: PAGE_BRIDGE_CHANNEL,
|
||||
...operation,
|
||||
timeoutMs,
|
||||
}, target.documentId ? { documentId: target.documentId } : { frameId: target.frameId }) as PageBridgeResponse;
|
||||
if (!response?.ok) throw new Error(response?.error?.message || '页面主世界执行失败');
|
||||
return response.result;
|
||||
},
|
||||
};
|
||||
|
||||
const userScriptsAdapter: PageExecutionAdapter = {
|
||||
mode: 'user-scripts',
|
||||
async execute(target, operation, timeoutMs) {
|
||||
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
|
||||
if (!userScripts?.execute) {
|
||||
throw new Error('User Scripts API 不可用;Chrome 138+ 还需要在扩展详情中启用“允许用户脚本”');
|
||||
}
|
||||
const input = JSON.stringify({ ...operation, timeoutMs }).replaceAll('<', '\\u003c');
|
||||
const code = `(${executeInUserScriptWorld.toString()})(${input},${evaluationSource(operation)})`;
|
||||
const [injection] = await userScripts.execute({
|
||||
target: target.documentId
|
||||
? { tabId: target.tabId, documentIds: [target.documentId] }
|
||||
: { tabId: target.tabId, frameIds: [target.frameId] },
|
||||
world: 'MAIN',
|
||||
js: [{ code }],
|
||||
});
|
||||
if (!injection) throw new Error('User Scripts API 没有返回主框架执行结果');
|
||||
if (injection.error) throw new Error(injection.error);
|
||||
const response = injection.result as UserScriptExecutionResponse | undefined;
|
||||
if (!response) throw new Error('User Scripts API 返回了空执行结果');
|
||||
if (!response.ok) throw new Error(response.error.message);
|
||||
return response.result;
|
||||
},
|
||||
};
|
||||
|
||||
const enterpriseAdapter: PageExecutionAdapter = {
|
||||
mode: 'user-scripts',
|
||||
async execute(target, operation, timeoutMs) {
|
||||
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
|
||||
if (!userScripts?.execute || !userScripts.getScripts) {
|
||||
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
|
||||
}
|
||||
try {
|
||||
await userScripts.getScripts();
|
||||
} catch {
|
||||
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
|
||||
}
|
||||
return userScriptsAdapter.execute(target, operation, timeoutMs);
|
||||
},
|
||||
};
|
||||
|
||||
const invokeOnlyAdapter: PageExecutionAdapter = {
|
||||
mode: 'invoke-only',
|
||||
async execute() {
|
||||
throw new Error('Firefox AMO 渠道仅提供结构化浏览器命令,不包含页面函数调用或 Eval');
|
||||
},
|
||||
};
|
||||
|
||||
const executionAdapter = import.meta.env.FIREFOX && import.meta.env.MODE === 'store'
|
||||
? invokeOnlyAdapter
|
||||
: !import.meta.env.FIREFOX
|
||||
&& (import.meta.env.MODE === 'production' || import.meta.env.MODE === 'store')
|
||||
? userScriptsAdapter
|
||||
: !import.meta.env.FIREFOX && import.meta.env.MODE === 'enterprise'
|
||||
? enterpriseAdapter
|
||||
: injectedBridgeAdapter;
|
||||
|
||||
export function getPageExecutionMode(): PageExecutionMode {
|
||||
return executionAdapter.mode;
|
||||
}
|
||||
|
||||
export function executePageOperation(target: BrowserTarget, operation: PageOperation, timeoutMs = 10_000): Promise<PageEvalResult> {
|
||||
return executionAdapter.execute(target, operation, Math.min(Math.max(timeoutMs, 250), 60_000));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import type { PageFrameSummary } from '@/types/models';
|
||||
|
||||
interface FrameProbe {
|
||||
title: string;
|
||||
name: string;
|
||||
origin: string;
|
||||
url: string;
|
||||
readyState: string;
|
||||
sandbox: string[];
|
||||
}
|
||||
|
||||
type FrameProbeResult = Browser.scripting.InjectionResult<FrameProbe> & { documentId?: string };
|
||||
|
||||
function probeFrame(): FrameProbe {
|
||||
let sandbox: string[] = [];
|
||||
try {
|
||||
sandbox = Array.from(window.frameElement?.getAttribute('sandbox')?.split(/\s+/).filter(Boolean) || []).slice(0, 32);
|
||||
} catch {
|
||||
// Cross-origin parent access is not required for frame inventory.
|
||||
}
|
||||
return {
|
||||
title: document.title.slice(0, 1_000),
|
||||
name: window.name.slice(0, 240),
|
||||
origin: location.origin,
|
||||
url: location.href.slice(0, 8_192),
|
||||
readyState: document.readyState,
|
||||
sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
function urlOrigin(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFrameInventory(tabId: number): Promise<PageFrameSummary[]> {
|
||||
const [navigationFrames, probeResults] = await Promise.all([
|
||||
browser.webNavigation.getAllFrames({ tabId }).catch(() => null),
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
world: 'MAIN',
|
||||
func: probeFrame,
|
||||
}).catch(() => [] as FrameProbeResult[]),
|
||||
]);
|
||||
const probes = new Map((probeResults as FrameProbeResult[]).map((probe) => [probe.frameId, probe]));
|
||||
const navigation = navigationFrames || [];
|
||||
const frameIds = new Set<number>([...navigation.map((frame) => frame.frameId), ...probes.keys()]);
|
||||
const topNavigation = navigation.find((frame) => frame.frameId === 0);
|
||||
const topProbe = probes.get(0)?.result;
|
||||
const topOrigin = topProbe?.origin && topProbe.origin !== 'null' ? topProbe.origin : urlOrigin(topNavigation?.url || topProbe?.url || '');
|
||||
return [...frameIds].sort((left, right) => left - right).slice(0, 256).map((frameId) => {
|
||||
const navigationFrame = navigation.find((frame) => frame.frameId === frameId);
|
||||
const injection = probes.get(frameId);
|
||||
const probe = injection?.result;
|
||||
const url = probe?.url || navigationFrame?.url || '';
|
||||
const detectedOrigin = probe?.origin && probe.origin !== 'null' ? probe.origin : urlOrigin(url);
|
||||
const origin = detectedOrigin || (navigationFrame?.parentFrameId === 0 && /^about:(blank|srcdoc)/.test(url) ? topOrigin : '');
|
||||
return {
|
||||
tabId,
|
||||
frameId,
|
||||
documentId: injection?.documentId || navigationFrame?.documentId,
|
||||
parentFrameId: navigationFrame?.parentFrameId ?? (frameId === 0 ? -1 : 0),
|
||||
parentDocumentId: navigationFrame?.parentDocumentId,
|
||||
url,
|
||||
origin,
|
||||
title: probe?.title || (frameId === 0 ? 'Main frame' : `Frame ${frameId}`),
|
||||
name: probe?.name || '',
|
||||
frameType: String(navigationFrame?.frameType || (frameId === 0 ? 'outermost_frame' : 'sub_frame')),
|
||||
documentLifecycle: String(navigationFrame?.documentLifecycle || 'active'),
|
||||
isTop: frameId === 0,
|
||||
sameOrigin: Boolean(origin && topOrigin && origin === topOrigin),
|
||||
accessible: Boolean(injection?.result),
|
||||
sandbox: probe?.sandbox || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { PAGE_LIFECYCLE_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type { PageLifecycleEvent } from '@/types/models';
|
||||
|
||||
const MAX_EVENTS_PER_TAB = 100;
|
||||
const MAX_PERSISTED_TABS = 16;
|
||||
const eventsByTab = new Map<number, PageLifecycleEvent[]>();
|
||||
const sessionStorage = (browser.storage as unknown as {
|
||||
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
|
||||
}).session;
|
||||
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
|
||||
function isLifecycleEvent(value: unknown): value is PageLifecycleEvent {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const event = value as Partial<PageLifecycleEvent>;
|
||||
return typeof event.id === 'string' && ['document', 'history', 'fragment'].includes(String(event.kind))
|
||||
&& Number.isSafeInteger(event.tabId) && Number.isSafeInteger(event.frameId)
|
||||
&& typeof event.url === 'string' && typeof event.timestamp === 'number';
|
||||
}
|
||||
|
||||
async function restore(): Promise<void> {
|
||||
if (!sessionStorage) return;
|
||||
try {
|
||||
const stored = await sessionStorage.get(PAGE_LIFECYCLE_STORAGE_KEY);
|
||||
const values = stored[PAGE_LIFECYCLE_STORAGE_KEY];
|
||||
if (!Array.isArray(values)) return;
|
||||
for (const entry of values.slice(-MAX_PERSISTED_TABS)) {
|
||||
if (!Array.isArray(entry) || typeof entry[0] !== 'number' || !Array.isArray(entry[1])) continue;
|
||||
eventsByTab.set(entry[0], entry[1].filter(isLifecycleEvent).slice(-MAX_EVENTS_PER_TAB));
|
||||
}
|
||||
} catch {
|
||||
// Lifecycle tracking remains available in memory.
|
||||
}
|
||||
}
|
||||
|
||||
const restored = restore();
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (!sessionStorage || persistTimer) return;
|
||||
persistTimer = globalThis.setTimeout(() => {
|
||||
persistTimer = undefined;
|
||||
void sessionStorage.set({ [PAGE_LIFECYCLE_STORAGE_KEY]: [...eventsByTab].slice(-MAX_PERSISTED_TABS) }).catch(() => undefined);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function record(
|
||||
kind: PageLifecycleEvent['kind'],
|
||||
details: { tabId: number; frameId: number; documentId?: string; url: string; timeStamp: number; transitionType?: string },
|
||||
): Promise<void> {
|
||||
if (details.tabId < 0 || !/^(https?|about):/i.test(details.url)) return;
|
||||
await restored;
|
||||
const event: PageLifecycleEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
kind,
|
||||
tabId: details.tabId,
|
||||
frameId: details.frameId,
|
||||
documentId: details.documentId,
|
||||
url: details.url.slice(0, 8_192),
|
||||
timestamp: details.timeStamp,
|
||||
transitionType: details.transitionType,
|
||||
};
|
||||
eventsByTab.set(details.tabId, [...(eventsByTab.get(details.tabId) || []), event].slice(-MAX_EVENTS_PER_TAB));
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
browser.webNavigation.onCommitted.addListener((details) => void record('document', details));
|
||||
browser.webNavigation.onHistoryStateUpdated.addListener((details) => void record('history', details));
|
||||
browser.webNavigation.onReferenceFragmentUpdated.addListener((details) => void record('fragment', details));
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
if (eventsByTab.delete(tabId)) schedulePersist();
|
||||
});
|
||||
|
||||
export async function getPageLifecycle(tabId: number, frameId: number, documentId?: string): Promise<PageLifecycleEvent[]> {
|
||||
await restored;
|
||||
return (eventsByTab.get(tabId) || []).filter((event) => event.frameId === frameId
|
||||
&& (!documentId || !event.documentId || event.documentId === documentId)).slice(-50);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PageEvalResult } from '@/types/models';
|
||||
|
||||
export const PAGE_BRIDGE_CHANNEL = 'yakit-page-bridge-v1';
|
||||
export const PAGE_REQUEST_EVENT = 'yakit:page-request:v1';
|
||||
export const PAGE_RESPONSE_EVENT = 'yakit:page-response:v1';
|
||||
|
||||
export type PageOperation =
|
||||
| { operation: 'eval'; mode: 'expression' | 'program'; code: string }
|
||||
| { operation: 'invoke'; path: string; args: unknown[] };
|
||||
|
||||
export type PageBridgeRequest = PageOperation & {
|
||||
id: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type PageBridgeResponse = {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: PageEvalResult;
|
||||
} | {
|
||||
id: string;
|
||||
ok: false;
|
||||
error: { name: string; message: string; stack?: string };
|
||||
};
|
||||
@@ -0,0 +1,763 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type {
|
||||
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
|
||||
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
|
||||
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
|
||||
} from '@/types/models';
|
||||
import { executePageOperation } from '@/features/page-context/execution-adapter';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
import { getPageLifecycle } from '@/features/page-context/lifecycle';
|
||||
import { CONTEXT_DIGEST_STORAGE_KEY } from '@/protocol/storage';
|
||||
import { listCookies } from '@/features/cookies/service';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
||||
|
||||
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
|
||||
const MAX_SCANNED_ELEMENTS = 10_000;
|
||||
const MAX_NODES = 400;
|
||||
const MAX_FORMS = 50;
|
||||
const MAX_HEADINGS = 80;
|
||||
const MAX_BODY_TEXT = 20 * 1024;
|
||||
const MAX_STORAGE_ENTRIES = 100;
|
||||
const MAX_STORAGE_VALUE = 4 * 1024;
|
||||
const MAX_STORAGE_BYTES = 128 * 1024;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
const trim = (value: string | null | undefined, max = 240) => (value || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||
const truncateUtf8 = (value: string, maxBytes: number) => {
|
||||
const bytes = encoder.encode(value);
|
||||
if (bytes.byteLength <= maxBytes) return { value, byteLength: bytes.byteLength, truncated: false };
|
||||
let end = maxBytes;
|
||||
while (end > 0) {
|
||||
try { return { value: decoder.decode(bytes.subarray(0, end)), byteLength: bytes.byteLength, truncated: true }; }
|
||||
catch { end -= 1; }
|
||||
}
|
||||
return { value: '', byteLength: bytes.byteLength, truncated: true };
|
||||
};
|
||||
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
|
||||
const nodes = new Map<string, Element>();
|
||||
const summaries = new Map<string, PageNodeSummary>();
|
||||
const nodeIds = new WeakMap<Element, string>();
|
||||
const semanticOccurrences = new Map<string, number>();
|
||||
const interactive: PageNodeSummary[] = [];
|
||||
const forms: PageFormSummary[] = [];
|
||||
const headings: Array<{ level: number; text: string }> = [];
|
||||
const meta: Record<string, string> = {};
|
||||
const limitsReached = new Set<string>();
|
||||
let scannedElementCount = 0;
|
||||
let passwordFieldCount = 0;
|
||||
let hasLoginControl = false;
|
||||
let hasLogoutControl = false;
|
||||
let hasAccountControl = false;
|
||||
let metaCount = 0;
|
||||
|
||||
const selectorHint = (element: Element) => {
|
||||
if (element.id) return `#${CSS.escape(element.id)}`.slice(0, 240);
|
||||
const testId = element.getAttribute('data-testid');
|
||||
if (testId) return `[data-testid="${CSS.escape(testId)}"]`.slice(0, 240);
|
||||
const name = element.getAttribute('name');
|
||||
if (name) return `${element.tagName.toLowerCase()}[name="${CSS.escape(name)}"]`.slice(0, 240);
|
||||
const role = element.getAttribute('role');
|
||||
return `${element.tagName.toLowerCase()}${role ? `[role="${CSS.escape(role)}"]` : ''}`.slice(0, 240);
|
||||
};
|
||||
const accessibleName = (element: Element) => {
|
||||
const labelledBy = element.getAttribute('aria-labelledby');
|
||||
const labelledText = labelledBy?.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
|
||||
const labels = 'labels' in element
|
||||
? Array.from((element as HTMLInputElement).labels || []).map((label) => label.textContent || '').join(' ')
|
||||
: '';
|
||||
return trim(element.getAttribute('aria-label') || labelledText || labels || element.getAttribute('alt')
|
||||
|| element.getAttribute('title') || element.getAttribute('placeholder') || element.textContent);
|
||||
};
|
||||
const semanticBase = (element: Element, name: string) => {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (element.id) return `${tag}#${trim(element.id, 120)}`;
|
||||
const testId = element.getAttribute('data-testid');
|
||||
if (testId) return `${tag}[testid=${trim(testId, 120)}]`;
|
||||
const fieldName = element.getAttribute('name');
|
||||
if (fieldName) return `${tag}[name=${trim(fieldName, 120)}]`;
|
||||
let href = '';
|
||||
const rawHref = element.getAttribute('href');
|
||||
if (rawHref) {
|
||||
try {
|
||||
const parsed = new URL(rawHref, location.href);
|
||||
href = `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
href = rawHref.split('?')[0];
|
||||
}
|
||||
}
|
||||
return `${tag}|${element.getAttribute('role') || ''}|${element.getAttribute('type') || ''}|${trim(href, 180)}|${name}`;
|
||||
};
|
||||
const register = (element: Element, shadowDepth: number) => {
|
||||
const existing = nodeIds.get(element);
|
||||
if (existing) return summaries.get(existing);
|
||||
if (nodes.size >= MAX_NODES) {
|
||||
limitsReached.add('interactive_nodes');
|
||||
return undefined;
|
||||
}
|
||||
const name = accessibleName(element);
|
||||
const base = semanticBase(element, name);
|
||||
const occurrence = semanticOccurrences.get(base) || 0;
|
||||
semanticOccurrences.set(base, occurrence + 1);
|
||||
const nodeId = `n${(nodes.size + 1).toString(36)}`;
|
||||
const style = getComputedStyle(element);
|
||||
const visible = element.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
const rawHref = element.getAttribute('href');
|
||||
let href: string | undefined;
|
||||
if (rawHref) {
|
||||
try { href = new URL(rawHref, location.href).href.slice(0, 2_048); } catch { href = rawHref.slice(0, 2_048); }
|
||||
}
|
||||
const control = element as HTMLInputElement;
|
||||
const summary: PageNodeSummary = {
|
||||
nodeId,
|
||||
semanticKey: `${base}|${occurrence}`.slice(0, 500),
|
||||
tag: element.tagName.toLowerCase(),
|
||||
role: trim(element.getAttribute('role'), 120),
|
||||
type: trim(element.getAttribute('type'), 120),
|
||||
name: trim(element.getAttribute('name'), 240),
|
||||
text: trim(element.textContent),
|
||||
accessibleName: name,
|
||||
selectorHint: selectorHint(element),
|
||||
visible,
|
||||
disabled: Boolean(control.disabled || element.getAttribute('aria-disabled') === 'true'),
|
||||
required: Boolean(control.required || element.getAttribute('aria-required') === 'true'),
|
||||
...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type) ? { checked: element.checked } : {}),
|
||||
...(href ? { href } : {}),
|
||||
...(element.getAttribute('placeholder') ? { placeholder: trim(element.getAttribute('placeholder')) } : {}),
|
||||
...(element.getAttribute('autocomplete') ? { autocomplete: trim(element.getAttribute('autocomplete')) } : {}),
|
||||
shadowDepth,
|
||||
};
|
||||
nodes.set(nodeId, element);
|
||||
nodeIds.set(element, nodeId);
|
||||
summaries.set(nodeId, summary);
|
||||
return summary;
|
||||
};
|
||||
|
||||
const interactiveSelector = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[contenteditable="true"]';
|
||||
const visitRoot = (root: Document | ShadowRoot, shadowDepth: number) => {
|
||||
if (root instanceof ShadowRoot && root.host.tagName.toLowerCase() === 'yakit-browser-agent') return;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const element = current as Element;
|
||||
if (scannedElementCount >= MAX_SCANNED_ELEMENTS) {
|
||||
limitsReached.add('scanned_elements');
|
||||
return;
|
||||
}
|
||||
scannedElementCount += 1;
|
||||
if (input.options.includeDom !== false && element.matches(interactiveSelector)) {
|
||||
const summary = register(element, shadowDepth);
|
||||
if (summary) {
|
||||
interactive.push(summary);
|
||||
const label = String(summary.accessibleName || summary.text || '');
|
||||
if (element instanceof HTMLInputElement && element.type === 'password') passwordFieldCount += 1;
|
||||
if (/\b(log\s?in|sign\s?in)\b|登录|登入/i.test(label)) hasLoginControl = true;
|
||||
if (/\b(log\s?out|sign\s?out)\b|退出|注销/i.test(label)) hasLogoutControl = true;
|
||||
if (/\b(account|profile|dashboard)\b|账户|账号|个人中心/i.test(label)) hasAccountControl = true;
|
||||
}
|
||||
}
|
||||
if (input.options.includeDom !== false && /^H[1-6]$/.test(element.tagName) && headings.length < MAX_HEADINGS) {
|
||||
headings.push({ level: Number(element.tagName.slice(1)), text: trim(element.textContent, 500) });
|
||||
}
|
||||
if (input.options.includeDom !== false && element instanceof HTMLFormElement && forms.length < MAX_FORMS) {
|
||||
const formSummary = register(element, shadowDepth);
|
||||
if (formSummary) {
|
||||
const fieldNodeIds: string[] = [];
|
||||
const fieldWalker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
|
||||
let field = fieldWalker.nextNode();
|
||||
while (field && fieldNodeIds.length < 100) {
|
||||
const fieldElement = field as Element;
|
||||
if (fieldElement.matches('input,select,textarea,button')) {
|
||||
const fieldNodeId = register(fieldElement, shadowDepth)?.nodeId;
|
||||
if (fieldNodeId) fieldNodeIds.push(fieldNodeId);
|
||||
}
|
||||
field = fieldWalker.nextNode();
|
||||
}
|
||||
forms.push({
|
||||
nodeId: formSummary.nodeId,
|
||||
semanticKey: formSummary.semanticKey,
|
||||
action: element.action.slice(0, 2_048),
|
||||
method: element.method || 'get',
|
||||
name: element.name.slice(0, 240),
|
||||
fieldNodeIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (element instanceof HTMLMetaElement && metaCount < 80) {
|
||||
const key = element.getAttribute('name') || element.getAttribute('property') || '';
|
||||
if (key) {
|
||||
meta[key.slice(0, 240)] = (element.content || '').slice(0, 2_048);
|
||||
metaCount += 1;
|
||||
}
|
||||
}
|
||||
if (element.shadowRoot) visitRoot(element.shadowRoot, shadowDepth + 1);
|
||||
current = walker.nextNode();
|
||||
}
|
||||
};
|
||||
if (input.options.includeDom !== false) visitRoot(document, 0);
|
||||
if (headings.length >= MAX_HEADINGS) limitsReached.add('headings');
|
||||
if (forms.length >= MAX_FORMS) limitsReached.add('forms');
|
||||
|
||||
const storageError = (error: unknown) => {
|
||||
try { return (error instanceof Error ? error.message : String(error)).slice(0, 500); }
|
||||
catch { return 'Storage access failed'; }
|
||||
};
|
||||
const readStorage = (name: 'localStorage' | 'sessionStorage'): PageStorageSummary => {
|
||||
const entries: Array<{ key: string; value: string; byteLength: number; authRelated: boolean; truncated: boolean }> = [];
|
||||
let approximateBytes = 0;
|
||||
let storage: Storage | undefined;
|
||||
try {
|
||||
storage = globalThis[name];
|
||||
} catch (error) {
|
||||
return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false, error: storageError(error) };
|
||||
}
|
||||
if (!storage) return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false };
|
||||
let totalEntries = 0;
|
||||
try {
|
||||
totalEntries = storage.length;
|
||||
for (let index = 0; index < totalEntries && entries.length < MAX_STORAGE_ENTRIES; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (!key) continue;
|
||||
const raw = storage.getItem(key) || '';
|
||||
if (approximateBytes >= MAX_STORAGE_BYTES) break;
|
||||
const bounded = truncateUtf8(raw, Math.min(MAX_STORAGE_VALUE, MAX_STORAGE_BYTES - approximateBytes));
|
||||
approximateBytes += encoder.encode(bounded.value).byteLength;
|
||||
entries.push({ key: key.slice(0, 500), value: bounded.value, byteLength: bounded.byteLength, authRelated: /(auth|token|jwt|session|login|user|csrf|sid)/i.test(key), truncated: bounded.truncated });
|
||||
}
|
||||
return { supported: true, entries, totalEntries, approximateBytes, truncated: entries.length < totalEntries };
|
||||
} catch (error) {
|
||||
return { supported: true, entries, totalEntries, approximateBytes, truncated: true, error: storageError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const collectStorageInventory = async (): Promise<BrowserStorageInventory> => {
|
||||
const normalizeKey = (key: IDBValidKey): string | number => {
|
||||
if (typeof key === 'string') return key.slice(0, 500);
|
||||
if (typeof key === 'number') return key;
|
||||
if (key instanceof Date) return key.toISOString();
|
||||
if (Array.isArray(key)) return JSON.stringify(key).slice(0, 500);
|
||||
return `[binary key: ${key.byteLength} bytes]`;
|
||||
};
|
||||
const requestValue = <T,>(request: IDBRequest<T>, timeoutMs = 700): Promise<T> => new Promise((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => reject(new Error('IndexedDB request timed out')), timeoutMs);
|
||||
request.onsuccess = () => { globalThis.clearTimeout(timer); resolve(request.result); };
|
||||
request.onerror = () => { globalThis.clearTimeout(timer); reject(request.error || new Error('IndexedDB request failed')); };
|
||||
});
|
||||
let indexedDBApi: IDBFactory | undefined;
|
||||
let indexedDBAccessError: string | undefined;
|
||||
try { indexedDBApi = globalThis.indexedDB; }
|
||||
catch (error) { indexedDBAccessError = storageError(error); }
|
||||
const openDatabase = (api: IDBFactory, name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
|
||||
const request = api.open(name);
|
||||
let settled = false;
|
||||
const timer = globalThis.setTimeout(() => { settled = true; reject(new Error('IndexedDB open timed out')); }, 700);
|
||||
request.onsuccess = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
if (settled) request.result.close(); else { settled = true; resolve(request.result); }
|
||||
};
|
||||
request.onerror = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(request.error || new Error('IndexedDB open failed')); } };
|
||||
request.onblocked = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(new Error('IndexedDB open was blocked')); } };
|
||||
});
|
||||
const indexedResult: BrowserStorageInventory['indexedDB'] = {
|
||||
supported: Boolean(indexedDBApi && typeof indexedDBApi.databases === 'function'),
|
||||
databases: [],
|
||||
truncated: false,
|
||||
...(indexedDBAccessError ? { error: indexedDBAccessError } : {}),
|
||||
};
|
||||
if (indexedResult.supported && indexedDBApi) {
|
||||
try {
|
||||
const allDatabases = await Promise.race([
|
||||
indexedDBApi.databases(),
|
||||
new Promise<never>((_, reject) => globalThis.setTimeout(() => reject(new Error('IndexedDB inventory timed out')), 1_000)),
|
||||
]);
|
||||
const databases = allDatabases.filter((database) => database.name).slice(0, 10);
|
||||
indexedResult.truncated = allDatabases.length > databases.length;
|
||||
let remainingStores = 50;
|
||||
for (const databaseInfo of databases) {
|
||||
const name = databaseInfo.name!;
|
||||
try {
|
||||
const database = await openDatabase(indexedDBApi, name);
|
||||
const storeNames = Array.from(database.objectStoreNames).slice(0, Math.min(20, remainingStores));
|
||||
const databaseSummary: BrowserStorageInventory['indexedDB']['databases'][number] = {
|
||||
name: name.slice(0, 500), version: database.version, stores: [],
|
||||
truncated: database.objectStoreNames.length > storeNames.length,
|
||||
};
|
||||
if (storeNames.length > 0) {
|
||||
for (const storeName of storeNames) {
|
||||
try {
|
||||
const store = database.transaction(storeName, 'readonly').objectStore(storeName);
|
||||
const [count, keys] = await Promise.all([
|
||||
requestValue(store.count()),
|
||||
requestValue(store.getAllKeys(undefined, 10)),
|
||||
]);
|
||||
databaseSummary.stores.push({
|
||||
name: storeName.slice(0, 500),
|
||||
keyPath: typeof store.keyPath === 'string'
|
||||
? store.keyPath.slice(0, 500)
|
||||
: Array.isArray(store.keyPath) ? store.keyPath.map((item) => item.slice(0, 500)).slice(0, 20) : null,
|
||||
autoIncrement: store.autoIncrement,
|
||||
count,
|
||||
sampleKeys: keys.map(normalizeKey),
|
||||
truncated: count > keys.length,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseSummary.stores.push({
|
||||
name: storeName.slice(0, 500), keyPath: null, autoIncrement: false, sampleKeys: [], truncated: true,
|
||||
error: storageError(error),
|
||||
});
|
||||
}
|
||||
remainingStores -= 1;
|
||||
}
|
||||
}
|
||||
database.close();
|
||||
indexedResult.databases.push(databaseSummary);
|
||||
if (remainingStores <= 0) { indexedResult.truncated = true; break; }
|
||||
} catch (error) {
|
||||
indexedResult.databases.push({
|
||||
name: name.slice(0, 500), version: databaseInfo.version || 0, stores: [], truncated: true,
|
||||
error: storageError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
indexedResult.error = storageError(error);
|
||||
}
|
||||
}
|
||||
let cacheStorageApi: CacheStorage | undefined;
|
||||
let cacheStorageAccessError: string | undefined;
|
||||
try { cacheStorageApi = globalThis.caches; }
|
||||
catch (error) { cacheStorageAccessError = storageError(error); }
|
||||
const cacheResult: BrowserStorageInventory['cacheStorage'] = {
|
||||
supported: Boolean(cacheStorageApi && typeof cacheStorageApi.keys === 'function'),
|
||||
names: [],
|
||||
truncated: false,
|
||||
...(cacheStorageAccessError ? { error: cacheStorageAccessError } : {}),
|
||||
};
|
||||
if (cacheResult.supported && cacheStorageApi) {
|
||||
try {
|
||||
const names = await cacheStorageApi.keys();
|
||||
cacheResult.names = names.slice(0, 50).map((name) => name.slice(0, 500));
|
||||
cacheResult.truncated = names.length > cacheResult.names.length;
|
||||
} catch (error) {
|
||||
cacheResult.error = storageError(error);
|
||||
}
|
||||
}
|
||||
return { indexedDB: indexedResult, cacheStorage: cacheResult };
|
||||
};
|
||||
|
||||
const cryptoPattern = /(encrypt|decrypt|crypto|cipher|sign|hash|md5|sha|aes|rsa|sm2|sm3|sm4|encode|decode)/i;
|
||||
const cryptoCandidates: Array<{ path: string; kind: string }> = [];
|
||||
for (const key of Object.getOwnPropertyNames(window).slice(0, 5_000)) {
|
||||
if (!cryptoPattern.test(key)) continue;
|
||||
try { cryptoCandidates.push({ path: key, kind: typeof Reflect.get(window, key) }); }
|
||||
catch { cryptoCandidates.push({ path: key, kind: 'unreadable' }); }
|
||||
if (cryptoCandidates.length >= 100) break;
|
||||
}
|
||||
const collectBodyText = () => {
|
||||
if (input.options.includeDom === false || !document.body) return { value: '', truncated: false };
|
||||
const parts: string[] = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let remaining = MAX_BODY_TEXT;
|
||||
let visited = 0;
|
||||
let truncated = false;
|
||||
let current = walker.nextNode();
|
||||
while (current && remaining > 0 && visited < 5_000) {
|
||||
visited += 1;
|
||||
const parent = current.parentElement;
|
||||
if (parent && !parent.closest('script,style,noscript,template,[hidden],[aria-hidden="true"],yakit-browser-agent')) {
|
||||
const raw = current.nodeValue || '';
|
||||
const normalized = raw.slice(0, MAX_BODY_TEXT).replace(/\s+/g, ' ').trim();
|
||||
if (normalized) {
|
||||
const separatorBytes = parts.length ? 1 : 0;
|
||||
if (remaining <= separatorBytes) { truncated = true; break; }
|
||||
const bounded = truncateUtf8(normalized, remaining - separatorBytes);
|
||||
parts.push(bounded.value);
|
||||
remaining -= encoder.encode(bounded.value).byteLength + separatorBytes;
|
||||
truncated ||= bounded.truncated || raw.length > MAX_BODY_TEXT;
|
||||
}
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
if (current || visited >= 5_000) truncated = true;
|
||||
return { value: parts.join('\n'), truncated };
|
||||
};
|
||||
const bodyText = collectBodyText();
|
||||
let storageInventory: BrowserStorageInventory | undefined;
|
||||
if (input.options.includeStorage) {
|
||||
try {
|
||||
storageInventory = await collectStorageInventory();
|
||||
} catch (error) {
|
||||
const message = storageError(error);
|
||||
storageInventory = {
|
||||
indexedDB: { supported: false, databases: [], truncated: false, error: message },
|
||||
cacheStorage: { supported: false, names: [], truncated: false, error: message },
|
||||
};
|
||||
}
|
||||
}
|
||||
const localStorageSummary = input.options.includeStorage ? readStorage('localStorage') : undefined;
|
||||
const sessionStorageSummary = input.options.includeStorage ? readStorage('sessionStorage') : undefined;
|
||||
const registry = { captureId: input.captureId, nodes, summaries };
|
||||
Reflect.set(globalThis, registryKey, registry);
|
||||
return {
|
||||
document: {
|
||||
title: document.title.slice(0, 1_000),
|
||||
url: location.href.slice(0, 8_192),
|
||||
referrer: document.referrer.slice(0, 8_192),
|
||||
language: (document.documentElement.lang || navigator.language).slice(0, 100),
|
||||
charset: document.characterSet,
|
||||
readyState: document.readyState,
|
||||
bodyText: bodyText.value,
|
||||
bodyTextTruncated: bodyText.truncated,
|
||||
headings,
|
||||
forms,
|
||||
interactive,
|
||||
meta,
|
||||
localStorage: localStorageSummary,
|
||||
sessionStorage: sessionStorageSummary,
|
||||
storageInventory,
|
||||
cryptoCandidates,
|
||||
scannedElementCount,
|
||||
limitsReached: [...limitsReached],
|
||||
},
|
||||
authenticationSeed: { passwordFieldCount, hasLoginControl, hasLogoutControl, hasAccountControl },
|
||||
};
|
||||
}
|
||||
|
||||
interface ContextDigest {
|
||||
captureId: string;
|
||||
documentId?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
authentication: PageAuthenticationSignals['status'];
|
||||
included: string;
|
||||
nodes: Map<string, PageContextChange & { signature: string }>;
|
||||
formSignature: string;
|
||||
storageKeys: Set<string>;
|
||||
cookieNames: Set<string>;
|
||||
}
|
||||
|
||||
const contextDigests = new Map<string, ContextDigest>();
|
||||
const MAX_MEMORY_CONTEXT_DIGESTS = 32;
|
||||
const MAX_PERSISTED_CONTEXT_DIGESTS = 8;
|
||||
const contextSessionStorage = (browser.storage as unknown as {
|
||||
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
|
||||
}).session;
|
||||
let contextPersistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
|
||||
interface StoredContextDigest extends Omit<ContextDigest, 'nodes' | 'storageKeys' | 'cookieNames'> {
|
||||
nodes: Array<[string, PageContextChange & { signature: string }]>;
|
||||
storageKeys: string[];
|
||||
cookieNames: string[];
|
||||
}
|
||||
|
||||
function storedDigest(input: ContextDigest): StoredContextDigest {
|
||||
return { ...input, nodes: [...input.nodes], storageKeys: [...input.storageKeys], cookieNames: [...input.cookieNames] };
|
||||
}
|
||||
|
||||
async function restoreContextDigests(): Promise<void> {
|
||||
if (!contextSessionStorage) return;
|
||||
try {
|
||||
const stored = await contextSessionStorage.get(CONTEXT_DIGEST_STORAGE_KEY);
|
||||
const values = stored[CONTEXT_DIGEST_STORAGE_KEY];
|
||||
if (!Array.isArray(values)) return;
|
||||
for (const item of values.slice(-MAX_PERSISTED_CONTEXT_DIGESTS)) {
|
||||
const entry = item as Partial<StoredContextDigest> & { key?: unknown };
|
||||
if (typeof entry.key !== 'string' || typeof entry.captureId !== 'string' || typeof entry.title !== 'string'
|
||||
|| typeof entry.url !== 'string' || !Array.isArray(entry.nodes) || !Array.isArray(entry.storageKeys)
|
||||
|| !Array.isArray(entry.cookieNames) || !['authenticated', 'unauthenticated', 'unknown'].includes(String(entry.authentication))) continue;
|
||||
contextDigests.set(entry.key, {
|
||||
captureId: entry.captureId,
|
||||
documentId: typeof entry.documentId === 'string' ? entry.documentId : undefined,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
authentication: entry.authentication as PageAuthenticationSignals['status'],
|
||||
included: typeof entry.included === 'string' ? entry.included : 'dom',
|
||||
nodes: new Map(entry.nodes),
|
||||
formSignature: typeof entry.formSignature === 'string' ? entry.formSignature : '',
|
||||
storageKeys: new Set(entry.storageKeys.filter((value): value is string => typeof value === 'string')),
|
||||
cookieNames: new Set(entry.cookieNames.filter((value): value is string => typeof value === 'string')),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Context diff remains available in memory when session storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const contextDigestRestore = restoreContextDigests();
|
||||
|
||||
function scheduleContextDigestPersist(): void {
|
||||
if (!contextSessionStorage || contextPersistTimer) return;
|
||||
contextPersistTimer = globalThis.setTimeout(() => {
|
||||
contextPersistTimer = undefined;
|
||||
const values = [...contextDigests].slice(-MAX_PERSISTED_CONTEXT_DIGESTS).map(([key, digest]) => ({ key, ...storedDigest(digest) }));
|
||||
void contextSessionStorage.set({ [CONTEXT_DIGEST_STORAGE_KEY]: values }).catch(() => undefined);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
let changed = false;
|
||||
for (const key of contextDigests.keys()) {
|
||||
if (!key.startsWith(`${tabId}:`)) continue;
|
||||
contextDigests.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) scheduleContextDigestPersist();
|
||||
});
|
||||
|
||||
function difference(left: Set<string>, right: Set<string>, limit = 100): string[] {
|
||||
return [...left].filter((item) => !right.has(item)).slice(0, limit);
|
||||
}
|
||||
|
||||
async function contextDiff(context: Omit<PageContext, 'diff'>): Promise<PageContextDiff> {
|
||||
await contextDigestRestore;
|
||||
const key = `${context.target.tabId}:${context.target.frameId}`;
|
||||
const nodes = new Map(context.document.interactive.map((node) => [node.semanticKey, {
|
||||
semanticKey: node.semanticKey, tag: node.tag, text: node.accessibleName || node.text, nodeId: node.nodeId,
|
||||
signature: `${node.visible}|${node.disabled}|${node.required}|${node.checked ?? ''}|${node.href || ''}`,
|
||||
}]));
|
||||
const storageKeys = new Set([
|
||||
...(context.document.localStorage?.entries.map((entry) => `local:${entry.key}`) || []),
|
||||
...(context.document.sessionStorage?.entries.map((entry) => `session:${entry.key}`) || []),
|
||||
]);
|
||||
const cookieNames = new Set(context.authentication.cookieNames);
|
||||
const current: ContextDigest = {
|
||||
captureId: context.captureId,
|
||||
documentId: context.target.documentId,
|
||||
title: context.document.title,
|
||||
url: context.document.url,
|
||||
authentication: context.authentication.status,
|
||||
included: `${context.included.dom}:${context.included.storage}:${context.included.cookies}`,
|
||||
nodes,
|
||||
formSignature: context.document.forms.map((form) => `${form.semanticKey}|${form.method}|${form.action}|${form.fieldNodeIds.length}`).join('\n'),
|
||||
storageKeys,
|
||||
cookieNames,
|
||||
};
|
||||
const previous = contextDigests.get(key);
|
||||
contextDigests.delete(key);
|
||||
contextDigests.set(key, current);
|
||||
while (contextDigests.size > MAX_MEMORY_CONTEXT_DIGESTS) contextDigests.delete(contextDigests.keys().next().value!);
|
||||
scheduleContextDigestPersist();
|
||||
if (!previous) {
|
||||
return {
|
||||
kind: 'initial', toCaptureId: context.captureId, changedSections: [],
|
||||
addedNodes: [], removedNodes: [], addedStorageKeys: [], removedStorageKeys: [], addedCookieNames: [], removedCookieNames: [],
|
||||
};
|
||||
}
|
||||
const changedSections = new Set<PageContextDiff['changedSections'][number]>();
|
||||
const sameOptions = previous.included === current.included;
|
||||
const [previousDom, previousStorage, previousCookies] = previous.included.split(':').map((value) => value === 'true');
|
||||
if (!sameOptions) changedSections.add('capture_options');
|
||||
if (previous.title !== current.title || previous.url !== current.url || previous.documentId !== current.documentId) changedSections.add('document');
|
||||
if (sameOptions && previous.authentication !== current.authentication) changedSections.add('authentication');
|
||||
if (previousDom && context.included.dom && previous.formSignature !== current.formSignature) changedSections.add('forms');
|
||||
const addedNodes = previousDom && context.included.dom ? [...current.nodes.entries()].filter(([semanticKey, node]) => {
|
||||
const old = previous.nodes.get(semanticKey);
|
||||
return !old || old.signature !== node.signature;
|
||||
}).map(([, node]) => node).slice(0, 50) : [];
|
||||
const removedNodes = previousDom && context.included.dom ? [...previous.nodes.entries()].filter(([semanticKey, node]) => {
|
||||
const next = current.nodes.get(semanticKey);
|
||||
return !next || next.signature !== node.signature;
|
||||
}).map(([, node]) => ({ semanticKey: node.semanticKey, tag: node.tag, text: node.text })).slice(0, 50) : [];
|
||||
if (addedNodes.length || removedNodes.length) changedSections.add('interactive');
|
||||
const addedStorageKeys = previousStorage && context.included.storage ? difference(current.storageKeys, previous.storageKeys) : [];
|
||||
const removedStorageKeys = previousStorage && context.included.storage ? difference(previous.storageKeys, current.storageKeys) : [];
|
||||
if (addedStorageKeys.length || removedStorageKeys.length) changedSections.add('storage');
|
||||
const addedCookieNames = previousCookies && context.included.cookies ? difference(current.cookieNames, previous.cookieNames) : [];
|
||||
const removedCookieNames = previousCookies && context.included.cookies ? difference(previous.cookieNames, current.cookieNames) : [];
|
||||
if (addedCookieNames.length || removedCookieNames.length) changedSections.add('cookies');
|
||||
const documentChanged = Boolean(previous.documentId && current.documentId && previous.documentId !== current.documentId);
|
||||
return {
|
||||
kind: documentChanged ? 'document_changed' : changedSections.size ? 'changed' : 'unchanged',
|
||||
fromCaptureId: previous.captureId,
|
||||
toCaptureId: context.captureId,
|
||||
changedSections: [...changedSections], addedNodes, removedNodes,
|
||||
addedStorageKeys, removedStorageKeys, addedCookieNames, removedCookieNames,
|
||||
};
|
||||
}
|
||||
|
||||
function authenticationSignals(
|
||||
seed: { passwordFieldCount: number; hasLoginControl: boolean; hasLogoutControl: boolean; hasAccountControl: boolean },
|
||||
documentContext: PageContext['document'],
|
||||
cookieNames: string[],
|
||||
): PageAuthenticationSignals {
|
||||
const evidence: string[] = [];
|
||||
let score = 0;
|
||||
if (seed.hasLogoutControl) { score += 3; evidence.push('页面存在退出登录控件'); }
|
||||
if (seed.hasAccountControl) { score += 2; evidence.push('页面存在账户或个人中心控件'); }
|
||||
if (seed.passwordFieldCount > 0) { score -= 2; evidence.push(`页面存在 ${seed.passwordFieldCount} 个密码输入框`); }
|
||||
if (seed.hasLoginControl) { score -= 1; evidence.push('页面存在登录控件'); }
|
||||
const authCookieNames = cookieNames.filter((name) => /(auth|token|jwt|session|login|sid)/i.test(name));
|
||||
if (authCookieNames.length > 0) { score += 2; evidence.push(`发现 ${authCookieNames.length} 个疑似认证 Cookie 名称`); }
|
||||
const storageKeys = [
|
||||
...(documentContext.localStorage?.entries || []),
|
||||
...(documentContext.sessionStorage?.entries || []),
|
||||
].filter((entry) => entry.authRelated).map((entry) => entry.key);
|
||||
if (storageKeys.length > 0) { score += 2; evidence.push(`发现 ${storageKeys.length} 个疑似认证 Storage 键`); }
|
||||
return {
|
||||
status: score >= 2 ? 'authenticated' : score <= -2 ? 'unauthenticated' : 'unknown',
|
||||
confidence: Math.min(0.95, Math.round((0.3 + Math.abs(score) * 0.1) * 100) / 100),
|
||||
evidence: evidence.slice(0, 8),
|
||||
passwordFieldCount: seed.passwordFieldCount,
|
||||
cookieNames: cookieNames.slice(0, 200),
|
||||
storageKeys: storageKeys.slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
export async function capturePageContext(options: PageContextOptions = {}, input?: BrowserTarget | number): Promise<PageContext> {
|
||||
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
|
||||
if (!/^https?:/i.test(tab.url)) throw new Error('当前页面不允许采集上下文');
|
||||
const target = await resolveDocumentTarget(input || tab.id);
|
||||
const captureId = crypto.randomUUID();
|
||||
let injections: Array<Browser.scripting.InjectionResult & { error?: string }>;
|
||||
try {
|
||||
injections = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: collectDocumentContext,
|
||||
args: [{ options, captureId }],
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${message}`);
|
||||
}
|
||||
if (injections.length !== 1) throw new ExtensionError('context_capture_failed', '页面上下文采集无法唯一定位目标文档');
|
||||
const [{ result, error }] = injections;
|
||||
if (error) throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${error}`);
|
||||
if (result === undefined) throw new ExtensionError('context_capture_failed', '页面上下文采集脚本没有返回结果');
|
||||
const collected = result as Awaited<ReturnType<typeof collectDocumentContext>>;
|
||||
const [cookies, frames, lifecycle] = await Promise.all([
|
||||
options.includeCookies ? listCookies(collected.document.url) : undefined,
|
||||
getFrameInventory(target.tabId),
|
||||
getPageLifecycle(target.tabId, target.frameId, target.documentId),
|
||||
]);
|
||||
const authentication = authenticationSignals(collected.authenticationSeed, collected.document, cookies?.map((cookie) => cookie.name) || []);
|
||||
const contextWithoutDiff: Omit<PageContext, 'diff'> = {
|
||||
captureId,
|
||||
capturedAt: Date.now(),
|
||||
included: { dom: options.includeDom !== false, storage: options.includeStorage === true, cookies: options.includeCookies === true },
|
||||
tab,
|
||||
target,
|
||||
frames,
|
||||
lifecycle,
|
||||
authentication,
|
||||
document: collected.document,
|
||||
cookies,
|
||||
};
|
||||
return { ...contextWithoutDiff, diff: await contextDiff(contextWithoutDiff) };
|
||||
}
|
||||
|
||||
function operateRegisteredNode(input: { captureId: string; nodeId: string; operation: 'inspect' | PageNodeAction; value?: string }) {
|
||||
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
|
||||
const registry = Reflect.get(globalThis, registryKey) as {
|
||||
captureId?: string;
|
||||
nodes?: Map<string, Element>;
|
||||
summaries?: Map<string, PageNodeSummary>;
|
||||
} | undefined;
|
||||
if (!registry || registry.captureId !== input.captureId) {
|
||||
return { ok: false as const, code: 'stale_node', message: '上下文快照已经失效,请重新采集页面上下文' };
|
||||
}
|
||||
const element = registry.nodes?.get(input.nodeId);
|
||||
const summary = registry.summaries?.get(input.nodeId);
|
||||
if (!element || !summary || !element.isConnected) {
|
||||
return { ok: false as const, code: 'stale_node', message: '页面元素已被替换或移除,请重新采集页面上下文' };
|
||||
}
|
||||
const safeAttributes = new Set(['id', 'name', 'type', 'role', 'href', 'action', 'method', 'placeholder', 'autocomplete', 'disabled', 'required', 'checked', 'aria-label', 'aria-labelledby', 'aria-disabled', 'aria-required']);
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const attribute of Array.from(element.attributes).slice(0, 80)) {
|
||||
if (safeAttributes.has(attribute.name)) attributes[attribute.name] = attribute.value.slice(0, 2_048);
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const node = {
|
||||
...summary,
|
||||
connected: true,
|
||||
attributes,
|
||||
...(rect.width || rect.height ? { bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } } : {}),
|
||||
};
|
||||
if (input.operation === 'inspect') return { ok: true as const, node };
|
||||
const control = element as HTMLInputElement;
|
||||
if (input.operation === 'click') {
|
||||
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
|
||||
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
|
||||
}
|
||||
const click = (element as HTMLElement).click;
|
||||
if (typeof click !== 'function') return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持原生 click 操作' };
|
||||
globalThis.setTimeout(() => click.call(element), 0);
|
||||
} else if (input.operation === 'focus') {
|
||||
if (!(element instanceof HTMLElement)) return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持聚焦' };
|
||||
element.focus({ preventScroll: true });
|
||||
} else if (input.operation === 'scroll') {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
} else if (input.operation === 'setValue') {
|
||||
if (typeof input.value !== 'string') return { ok: false as const, code: 'invalid_node_action', message: 'setValue 缺少 value' };
|
||||
if (element instanceof HTMLInputElement) {
|
||||
if (element.type === 'file') return { ok: false as const, code: 'node_not_actionable', message: '不能通过 setValue 写入文件输入框' };
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setter?.call(element, input.value);
|
||||
} else if (element instanceof HTMLTextAreaElement) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
setter?.call(element, input.value);
|
||||
} else if (element instanceof HTMLSelectElement) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||
setter?.call(element, input.value);
|
||||
} else if (element instanceof HTMLElement && element.isContentEditable) {
|
||||
element.textContent = input.value;
|
||||
} else {
|
||||
return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持 setValue' };
|
||||
}
|
||||
element.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText', data: input.value }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
}
|
||||
return { ok: true as const, node };
|
||||
}
|
||||
|
||||
async function operateNode(
|
||||
captureId: string,
|
||||
nodeId: string,
|
||||
operation: 'inspect' | PageNodeAction,
|
||||
input: BrowserTarget | number,
|
||||
value?: string,
|
||||
): Promise<PageNodeDetails> {
|
||||
const target = await resolveDocumentTarget(input);
|
||||
const [{ result }] = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: operateRegisteredNode,
|
||||
args: [{ captureId, nodeId, operation, value }],
|
||||
});
|
||||
if (!result?.ok) throw new ExtensionError(result?.code || 'node_operation_failed', result?.message || '页面元素操作失败');
|
||||
return {
|
||||
...(result.node as unknown as PageNodeSummary),
|
||||
connected: true,
|
||||
attributes: (result.node.attributes || {}) as Record<string, string>,
|
||||
bounds: result.node.bounds,
|
||||
reference: { captureId, nodeId, ...target },
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectPageNode(captureId: string, nodeId: string, input: BrowserTarget | number): Promise<PageNodeDetails> {
|
||||
return operateNode(captureId, nodeId, 'inspect', input);
|
||||
}
|
||||
|
||||
export async function actOnPageNode(
|
||||
captureId: string,
|
||||
nodeId: string,
|
||||
action: PageNodeAction,
|
||||
input: BrowserTarget | number,
|
||||
value?: string,
|
||||
): Promise<PageNodeActionResult> {
|
||||
const node = await operateNode(captureId, nodeId, action, input, value);
|
||||
return { action, completedAt: Date.now(), node };
|
||||
}
|
||||
|
||||
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
|
||||
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
|
||||
const target = await resolveDocumentTarget(input || tab.id);
|
||||
return executePageOperation(target, { operation: 'invoke', path, args }, timeoutMs);
|
||||
}
|
||||
|
||||
export async function evalInPage(code: string, mode: 'expression' | 'program', input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
|
||||
if (!code.trim()) throw new Error('执行代码不能为空');
|
||||
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
|
||||
const target = await resolveDocumentTarget(input || tab.id);
|
||||
return executePageOperation(target, { operation: 'eval', mode, code }, timeoutMs);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProxyProfile, ProxyRule } from '@/types/models';
|
||||
import { compileProxyRules, previewProxyRules, proxyPatternMatches } 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 },
|
||||
];
|
||||
|
||||
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('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('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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview } from '@/types/models';
|
||||
|
||||
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 中`);
|
||||
const host = profile.host || '127.0.0.1';
|
||||
const port = profile.port || 8083;
|
||||
const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}`
|
||||
: profile.scheme === 'socks5' ? `SOCKS5 ${host}:${port}`
|
||||
: profile.scheme === 'https' ? `HTTPS ${host}:${port}` : `PROXY ${host}:${port}`;
|
||||
return failMode === 'open' ? `${proxy}; DIRECT` : proxy;
|
||||
}
|
||||
|
||||
function pacLiteral(value: string): string {
|
||||
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
||||
}
|
||||
|
||||
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}`)}))`;
|
||||
}
|
||||
if (pattern.includes('*') || pattern.includes('?')) return `shExpMatch(host, ${pacLiteral(pattern)})`;
|
||||
return `(host === ${pacLiteral(pattern)} || dnsDomainIs(host, ${pacLiteral(`.${pattern}`)}))`;
|
||||
}
|
||||
|
||||
export function compileProxyRules(
|
||||
rules: ProxyRule[],
|
||||
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))};`] : [];
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
|
||||
function wildcardRegexp(pattern: string): RegExp {
|
||||
return new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`, 'i');
|
||||
}
|
||||
|
||||
export function proxyPatternMatches(rawPattern: string, 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}`);
|
||||
}
|
||||
if (pattern.includes('*') || pattern.includes('?')) return wildcardRegexp(pattern).test(url.hostname);
|
||||
return url.hostname.toLowerCase() === pattern.toLowerCase() || url.hostname.toLowerCase().endsWith(`.${pattern.toLowerCase()}`);
|
||||
} 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')!;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY, PROXY_STATS_STORAGE_KEY } from '@/protocol/storage';
|
||||
import type {
|
||||
ExtensionState, ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview, ProxyRuleStats,
|
||||
} from '@/types/models';
|
||||
import { getState, updateState } from '@/platform/storage/state';
|
||||
import {
|
||||
compileProxyRules, previewProxyRules, proxyPatternMatches, sortedProxyRules,
|
||||
} from './compiler';
|
||||
|
||||
export { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
|
||||
|
||||
function isFirefox(): boolean {
|
||||
return Boolean(import.meta.env.FIREFOX);
|
||||
}
|
||||
|
||||
|
||||
function chromeProxyValue(profile: ProxyProfile): object {
|
||||
if (profile.kind === 'direct') return { mode: 'direct' };
|
||||
if (profile.kind === 'system') return { mode: 'system' };
|
||||
if (profile.kind === 'pac_script') {
|
||||
return {
|
||||
mode: 'pac_script',
|
||||
pacScript: profile.pacScript
|
||||
? { data: profile.pacScript, mandatory: true }
|
||||
: { url: profile.pacUrl, mandatory: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'fixed_servers',
|
||||
rules: {
|
||||
singleProxy: {
|
||||
scheme: profile.scheme || 'http',
|
||||
host: profile.host || '127.0.0.1',
|
||||
port: profile.port || 8083,
|
||||
},
|
||||
bypassList: profile.bypass,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firefoxProxyValue(profile: ProxyProfile): object {
|
||||
if (profile.kind === 'direct') return { proxyType: 'none' };
|
||||
if (profile.kind === 'system') return { proxyType: 'system' };
|
||||
if (profile.kind === 'pac_script') {
|
||||
return profile.pacUrl
|
||||
? { proxyType: 'autoConfig', autoConfigUrl: profile.pacUrl }
|
||||
: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(profile.pacScript || '')}` };
|
||||
}
|
||||
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(', '),
|
||||
};
|
||||
}
|
||||
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('代理配置不存在');
|
||||
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',
|
||||
});
|
||||
}
|
||||
await updateState((current) => ({ ...current, activeProxyId: 'rules' }));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>'] });
|
||||
|
||||
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;
|
||||
}
|
||||
return response;
|
||||
}, { urls: ['<all_urls>'] }, [isFirefox() ? 'blocking' : 'asyncBlocking']);
|
||||
|
||||
export async function setProxyAuthPassword(profileId: string, password: string): Promise<void> {
|
||||
if (password) authPasswords.set(profileId, password);
|
||||
else authPasswords.delete(profileId);
|
||||
if (sessionStorage) await sessionStorage.set({ [PROXY_AUTH_STORAGE_KEY]: Object.fromEntries(authPasswords) });
|
||||
}
|
||||
|
||||
export function hasProxyAuthPassword(profileId: string): boolean {
|
||||
return authPasswords.has(profileId);
|
||||
}
|
||||
|
||||
export function getProxyRuleStats(): ProxyRuleStats[] {
|
||||
return [...ruleStats.values()].sort((left, right) => right.hits - left.hits);
|
||||
}
|
||||
|
||||
export async function clearProxyRuleStats(): Promise<void> {
|
||||
ruleStats.clear();
|
||||
if (sessionStorage) await sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [] });
|
||||
}
|
||||
Reference in New Issue
Block a user