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:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent c8380de521
commit 0371a8b802
113 changed files with 15944 additions and 6556 deletions
+603
View File
@@ -0,0 +1,603 @@
import { browser, type Browser } from 'wxt/browser';
import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
startNetworkCapture, stopNetworkCapture, stopNetworkCapturesForGrant,
} from '@/features/network-capture/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import {
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
stopPageObservation, stopPageObservationsForGrant,
} from '@/features/page-observation/service';
import type { ExtensionRequest, ExtensionResponse } from '@/types/messages';
import { parseExtensionRequest } from '@/protocol/extension';
import type {
BridgeGrantTarget, BrowserRequestAnalysisBundle, BrowserTarget, YakPocGenerateResult, YakitFuzzerOpenResult,
} from '@/types/models';
import { engineBridge } from '@/features/engine-bridge/service';
import { getFrameInventory } from '@/features/page-context/frames';
import { getActiveTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import {
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
} from '@/features/page-context/service';
import { listCookies, removeCookie, setCookie } from '@/features/cookies/service';
import { exportCookies, importCookies } from '@/features/cookies/transfer';
import {
applyProxyRules, clearProxyRuleStats, compileProxyRules, getProxyRuleStats, hasProxyAuthPassword,
previewProxyRules, setProxyAuthPassword, switchProxy,
} from '@/features/proxy/service';
import { getState, updateState } from '@/platform/storage/state';
import { applyUserAgentRules } from '@/features/identity/user-agent';
import { errorCode, ExtensionError } from '@/shared/errors';
import { appendAuditEvent, clearAuditEvents, listAuditEvents } from '@/features/diagnostics/audit';
import {
clearAgentActions, getAgentRuntime, setAgentRuntimeState, startAgentRuntime,
} from '@/features/agent-runtime/service';
import {
applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy,
} from '@/platform/policy/managed';
import { createDiagnosticsBundle } from '@/features/diagnostics/export';
import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics';
function ok<T>(data?: T): ExtensionResponse<T> {
return { ok: true, data };
}
function fail(error: unknown): ExtensionResponse {
return { ok: false, error: error instanceof Error ? error.message : String(error), errorCode: errorCode(error) };
}
function isFloatingSender(sender: Browser.runtime.MessageSender): boolean {
try {
const parsed = new URL(sender.url || '');
return parsed.origin === new URL(browser.runtime.getURL('/')).origin && parsed.pathname === '/floating.html';
} catch {
return false;
}
}
function senderBoundTabId(sender: Browser.runtime.MessageSender): number | undefined {
const extensionOrigin = new URL(browser.runtime.getURL('/')).origin;
const senderUrl = sender.url || '';
try {
const parsed = new URL(senderUrl);
if (parsed.origin === extensionOrigin && parsed.pathname !== '/floating.html') return undefined;
} catch {
// Non-URL senders remain bound to their browser tab below.
}
return sender.tab?.id;
}
function targetTabId(requested: number | undefined, sender: Browser.runtime.MessageSender): number | undefined {
const senderTabId = senderBoundTabId(sender);
if (senderTabId && requested && senderTabId !== requested) {
throw new Error('页面内请求不能操作其他标签页');
}
return senderTabId || requested;
}
async function requestTarget(
input: { tabId?: number; frameId?: number; documentId?: string },
sender: Browser.runtime.MessageSender,
): Promise<BrowserTarget | undefined> {
const boundTabId = senderBoundTabId(sender);
if (boundTabId && input.tabId && boundTabId !== input.tabId) {
throw new ExtensionError('target_denied', '页面内请求不能操作其他标签页');
}
if (boundTabId && !isFloatingSender(sender)) {
const frameId = sender.frameId ?? 0;
if (input.frameId !== undefined && input.frameId !== frameId) throw new ExtensionError('target_denied', '页面内请求不能操作其他 frame');
if (input.documentId && sender.documentId && input.documentId !== sender.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新选择');
}
return { tabId: boundTabId, frameId, documentId: sender.documentId };
}
const tabId = boundTabId || input.tabId;
if (!tabId) return undefined;
return resolveDocumentTarget({ tabId, frameId: input.frameId ?? 0, documentId: input.documentId });
}
async function requiredRequestTarget(
input: { tabId?: number; frameId?: number; documentId?: string },
sender: Browser.runtime.MessageSender,
): Promise<BrowserTarget> {
const target = await requestTarget(input, sender);
if (!target) throw new ExtensionError('target_unavailable', '请选择一个可访问的 HTTP(S) 标签页');
return target;
}
function originOf(url: string): string {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('只能授权 HTTP(S) 标签页');
return parsed.origin;
}
async function createGrantTargets(inputs: Array<{ tabId: number; frameId: number }>): Promise<BridgeGrantTarget[]> {
const unique = [...new Map(inputs.map((target) => [`${target.tabId}:${target.frameId}`, target])).values()];
const tabIds = [...new Set(unique.map((target) => target.tabId))];
const inventories = new Map(await Promise.all(tabIds.map(async (tabId) => [tabId, await getFrameInventory(tabId)] as const)));
return Promise.all(unique.map(async (input) => {
const tab = await getTab(input.tabId);
const frame = inventories.get(input.tabId)?.find((item) => item.frameId === input.frameId);
if (!frame?.accessible || !frame.documentId || !frame.origin) {
throw new ExtensionError('target_unavailable', `Frame ${input.frameId} 当前不可访问,不能加入共享会话`);
}
originOf(`${frame.origin}/`);
return {
tabId: input.tabId,
frameId: frame.frameId,
documentId: frame.documentId,
origin: frame.origin,
grantedUrl: frame.url,
title: frame.isTop ? tab.title : `${tab.title} · ${frame.title || frame.name || `Frame ${frame.frameId}`}`,
};
}));
}
async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.MessageSender): Promise<ExtensionResponse> {
switch (request.action) {
case 'state.get': return ok(await getState());
case 'tab.active': {
const boundTabId = senderBoundTabId(sender);
return ok(boundTabId ? await getTab(boundTabId) : await getActiveTab());
}
case 'tab.get': return ok(await getTab(targetTabId(request.payload.tabId, sender)));
case 'tab.list': return ok((await browser.tabs.query({})).filter((tab) => tab.id && /^https?:/i.test(tab.url || '')).map((tab) => ({
id: tab.id!, windowId: tab.windowId, title: tab.title || '未命名页面', url: tab.url!, favIconUrl: tab.favIconUrl, lastAccessed: tab.lastAccessed,
})));
case 'frame.list': return ok(await getFrameInventory(targetTabId(request.payload.tabId, sender)!));
case 'proxy.save': {
const profile = request.payload;
return ok(await updateState((state) => ({
...state,
proxyProfiles: [...state.proxyProfiles.filter((item) => item.id !== profile.id), profile],
})));
}
case 'proxy.delete': {
const { id } = request.payload;
return ok(await updateState((state) => ({
...state,
proxyProfiles: state.proxyProfiles.filter((item) => item.id !== id || item.builtin),
proxyRules: state.proxyRules.filter((rule) => rule.proxyProfileId !== id),
})));
}
case 'proxy.switch':
await switchProxy(request.payload.id);
return ok(await getState());
case 'proxy.rule.save': {
const rule = request.payload;
const profiles = (await getState()).proxyProfiles;
if (!profiles.some((profile) => profile.id === rule.proxyProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) {
throw new Error('规则 PAC 只能使用直接连接或固定代理出口');
}
return ok(await updateState((state) => ({
...state,
proxyRules: [...state.proxyRules.filter((item) => item.id !== rule.id), rule],
})));
}
case 'proxy.rule.delete': {
const { id } = request.payload;
return ok(await updateState((state) => ({ ...state, proxyRules: state.proxyRules.filter((item) => item.id !== id) })));
}
case 'proxy.rules.apply':
await applyProxyRules();
return ok(await getState());
case 'proxy.rules.preview': {
const state = await getState();
return ok(previewProxyRules(request.payload.url, state.proxyRules, state.proxyProfiles, state.proxyRouting));
}
case 'proxy.rules.compile': {
const state = await getState();
return ok(compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting));
}
case 'proxy.rules.reorder': {
const ids = request.payload.ids;
const state = await getState();
if (ids.length !== state.proxyRules.length || new Set(ids).size !== ids.length || ids.some((id) => !state.proxyRules.some((rule) => rule.id === id))) {
throw new Error('规则排序必须包含当前全部规则且不能重复');
}
const byId = new Map(state.proxyRules.map((rule) => [rule.id, rule]));
return ok(await updateState((current) => ({
...current,
proxyRules: ids.map((id, index) => ({ ...byId.get(id)!, priority: (ids.length - index) * 10 })),
})));
}
case 'proxy.rules.settings': {
const input = request.payload;
const state = await getState();
if (!state.proxyProfiles.some((profile) => profile.id === input.defaultProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) throw new Error('默认出口必须是直接连接或固定代理');
return ok(await updateState((current) => ({ ...current, proxyRouting: input })));
}
case 'proxy.rules.stats': return ok(getProxyRuleStats());
case 'proxy.rules.stats.clear':
await clearProxyRuleStats();
return ok();
case 'proxy.auth.set':
await setProxyAuthPassword(request.payload.profileId, request.payload.password);
return ok({ configured: hasProxyAuthPassword(request.payload.profileId) });
case 'proxy.auth.status': return ok({ configured: hasProxyAuthPassword(request.payload.profileId) });
case 'proxy.config.export': {
const state = await getState();
return ok({ version: 1 as const, profiles: state.proxyProfiles, rules: state.proxyRules, routing: state.proxyRouting });
}
case 'proxy.config.import': {
const configuration = request.payload.configuration;
const profileIds = new Set(configuration.profiles.map((profile) => profile.id));
if (profileIds.size !== configuration.profiles.length || !profileIds.has(configuration.routing.defaultProfileId)) throw new Error('代理配置包含重复或缺失的出口 ID');
if (configuration.rules.some((rule) => !profileIds.has(rule.proxyProfileId))) throw new Error('代理规则引用了不存在的出口');
const routableIds = new Set(configuration.profiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id));
if (!routableIds.has(configuration.routing.defaultProfileId) || configuration.rules.some((rule) => !routableIds.has(rule.proxyProfileId))) throw new Error('规则 PAC 只能使用直接连接或固定代理出口');
return ok(await updateState((current) => ({
...current,
proxyProfiles: configuration.profiles,
proxyRules: configuration.rules,
proxyRouting: configuration.routing,
activeProxyId: 'direct',
})));
}
case 'cookie.list': return ok(await listCookies(request.payload.url));
case 'cookie.set': return ok(await setCookie(request.payload));
case 'cookie.remove': {
const input = request.payload;
await removeCookie(input);
return ok();
}
case 'cookie.removeMany': {
const results = await Promise.allSettled(request.payload.cookies.map((cookie) => removeCookie(cookie)));
const removed = results.filter((result) => result.status === 'fulfilled').length;
return ok({ removed, failed: results.length - removed });
}
case 'cookie.import': return ok(await importCookies(request.payload.url, request.payload.format, request.payload.text));
case 'cookie.export': return ok(exportCookies(await listCookies(request.payload.url), request.payload.format, request.payload.includeValues));
case 'ua.save': {
const rule = request.payload;
const state = await updateState((current) => ({
...current,
userAgentRules: [...current.userAgentRules.filter((item) => item.id !== rule.id), rule],
}));
if (state.activeGrant) await startAgentRuntime(state.activeGrant);
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'ua.delete': {
const state = await updateState((current) => ({
...current,
userAgentRules: current.userAgentRules.filter((item) => item.id !== request.payload.id),
}));
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'ua.apply': {
const state = await getState();
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'context.capture': {
const { tabId, frameId, documentId, ...options } = request.payload;
const target = await requiredRequestTarget({ tabId, frameId, documentId }, sender);
const context = await capturePageContext(options, target);
void appendAuditEvent({
category: 'capability', action: 'context.capture', outcome: 'success', targetTabId: target.tabId,
summary: `${context.document.interactive.length} 个节点,${context.diff.kind}`,
});
return ok(context);
}
case 'context.node.inspect': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
return ok(await inspectPageNode(input.captureId, input.nodeId, target));
}
case 'context.node.action': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const result = await actOnPageNode(input.captureId, input.nodeId, input.action, target, input.value);
void appendAuditEvent({
category: 'capability', action: `context.node.${input.action}`, outcome: 'success', targetTabId: target.tabId,
summary: input.nodeId,
});
return ok(result);
}
case 'context.invoke': {
const input = request.payload;
return ok(await invokePageFunction(input.path, input.args, await requestTarget(input, sender), input.timeoutMs));
}
case 'context.eval': {
const input = request.payload;
return ok(await evalInPage(input.code, input.mode, await requestTarget(input, sender), input.timeoutMs));
}
case 'panel.update': {
const input = request.payload;
const policy = (await getEnterprisePolicy()).policy;
return ok(await updateState((current) => applyPolicyToState({
...current, floatingPanel: {
enabled: input.enabled ?? current.floatingPanel.enabled,
side: input.side ?? current.floatingPanel.side,
y: typeof input.y === 'number' ? Math.min(Math.max(input.y, 0.08), 0.92) : current.floatingPanel.y,
displayMode: input.displayMode ?? current.floatingPanel.displayMode,
siteMode: input.siteMode ?? current.floatingPanel.siteMode,
siteOrigins: input.siteOrigins
? [...new Set(input.siteOrigins.map((origin) => new URL(origin).origin))]
: current.floatingPanel.siteOrigins,
shortcutEnabled: input.shortcutEnabled ?? current.floatingPanel.shortcutEnabled,
autoCollapseFullscreen: input.autoCollapseFullscreen ?? current.floatingPanel.autoCollapseFullscreen,
},
}, policy)));
}
case 'grant.create': {
const input = request.payload;
const boundTabId = senderBoundTabId(sender);
if (boundTabId && input.targets.some((target) => target.tabId !== boundTabId)) {
throw new Error('页面内请求只能授权当前标签页');
}
const now = Date.now();
const targets = await createGrantTargets(input.targets);
const policy = (await getEnterprisePolicy()).policy;
const durationMinutes = assertGrantPolicy(policy, {
durationMinutes: input.durationMinutes,
origins: targets.map((target) => target.origin),
programEval: input.scopes.includes('browser.page.eval.program'),
});
const before = await getState();
const state = await updateState((current) => ({
...current,
activeGrant: {
id: crypto.randomUUID(),
taskId: input.taskId || `manual-${crypto.randomUUID()}`,
targets,
scopes: [...new Set(input.scopes)],
createdAt: now,
expiresAt: now + durationMinutes * 60_000,
},
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: now }
: current.handoff,
}));
if (before.activeGrant) {
await Promise.all([
stopNetworkCapturesForGrant(before.activeGrant.id),
stopPageObservationsForGrant(before.activeGrant.id),
]);
}
if (before.handoff?.state === 'waiting_for_user' && state.handoff) {
await browser.action.setBadgeText({ text: '', tabId: before.handoff.target.tabId });
engineBridge.emitEvent('browser.handoff.changed', state.handoff);
void appendAuditEvent({
category: 'handoff', action: 'handoff.cancelled', outcome: 'cancelled',
taskId: before.handoff.taskId, targetTabId: before.handoff.target.tabId,
summary: '创建新授权会话时取消',
});
}
void appendAuditEvent({
category: 'grant', action: 'grant.create', outcome: 'success', taskId: state.activeGrant?.taskId,
targetTabId: state.activeGrant?.targets[0]?.tabId,
summary: `${state.activeGrant?.targets.length || 0} 个标签页,${state.activeGrant?.scopes.length || 0} 项能力`,
});
return ok(state);
}
case 'grant.revoke': {
const before = await getState();
engineBridge.cancelActiveRequests();
const state = await updateState((current) => ({
...current,
activeGrant: undefined,
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
: current.handoff,
}));
if (before.activeGrant) {
await Promise.all([
stopNetworkCapturesForGrant(before.activeGrant.id),
stopPageObservationsForGrant(before.activeGrant.id),
]);
}
await setAgentRuntimeState('revoked', before.activeGrant);
if (state.handoff && before.handoff?.state === 'waiting_for_user') engineBridge.emitEvent('browser.handoff.changed', state.handoff);
if (before.handoff?.state === 'waiting_for_user') {
await browser.action.setBadgeText({ text: '', tabId: before.handoff.target.tabId });
void appendAuditEvent({
category: 'handoff', action: 'handoff.cancelled', outcome: 'cancelled',
taskId: before.handoff.taskId, targetTabId: before.handoff.target.tabId,
summary: '撤销授权会话时取消',
});
}
void appendAuditEvent({
category: 'grant', action: 'grant.revoke', outcome: 'success', taskId: before.activeGrant?.taskId,
targetTabId: before.activeGrant?.targets[0]?.tabId,
});
return ok(state);
}
case 'handoff.resolve': {
const input = request.payload;
const state = await updateState((current) => {
if (!current.handoff || current.handoff.id !== input.id || current.handoff.state !== 'waiting_for_user') {
throw new ExtensionError('handoff_not_waiting', '人工接管请求不存在或已经结束');
}
return {
...current,
handoff: { ...current.handoff, state: input.outcome, resolvedAt: Date.now() },
};
});
const handoff = state.handoff!;
await setAgentRuntimeState(input.outcome === 'completed' ? 'running' : 'paused', state.activeGrant);
await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
engineBridge.emitEvent('browser.handoff.changed', handoff);
void appendAuditEvent({
category: 'handoff', action: `handoff.${input.outcome}`, outcome: input.outcome === 'completed' ? 'success' : 'cancelled',
taskId: handoff.taskId, targetTabId: handoff.target.tabId,
});
return ok(state);
}
case 'network.capture.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const status = await startNetworkCapture(target, input);
void appendAuditEvent({
category: 'capability', action: 'network.capture.start', outcome: 'success', targetTabId: target.tabId,
summary: input.captureHeaders || input.captureBody ? '包含用户明确启用的敏感字段' : '仅元数据',
});
return ok(status);
}
case 'network.capture.status': return ok(await networkCaptureStatus(await requiredRequestTarget(request.payload, sender)));
case 'network.capture.list': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(await listNetworkRequests(target, request.payload.limit));
}
case 'network.capture.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await clearNetworkRequests(target);
void appendAuditEvent({ category: 'capability', action: 'network.capture.clear', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'network.capture.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await stopNetworkCapture(target);
void appendAuditEvent({ category: 'capability', action: 'network.capture.stop', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'network.capture.export': {
const target = await requiredRequestTarget(request.payload, sender);
const exported = await exportNetworkRequest(target, request.payload.id);
void appendAuditEvent({ category: 'capability', action: 'network.capture.export', outcome: 'success', targetTabId: target.tabId });
return ok(exported);
}
case 'network.capture.send': {
const target = await requiredRequestTarget(request.payload, sender);
try {
const exported = await exportNetworkRequest(target, request.payload.id);
const result = await engineBridge.requestEngine<YakitFuzzerOpenResult>('yakit.web_fuzzer.open', {
rawRequestBase64: exported.rawRequestBase64,
isHttps: exported.isHttps,
tabName: `Browser · ${new URL(exported.url).hostname}`,
});
void appendAuditEvent({
category: 'capability', action: 'network.capture.send_to_fuzzer', outcome: 'success', targetTabId: target.tabId,
summary: `Web Fuzzer ${result.pageId}`,
});
return ok(result);
} catch (error) {
void appendAuditEvent({
category: 'capability', action: 'network.capture.send_to_fuzzer', outcome: 'error',
targetTabId: target.tabId, errorCode: errorCode(error),
});
throw error;
}
}
case 'network.capture.poc': {
const target = await requiredRequestTarget(request.payload, sender);
const result = await engineBridge.requestEngine<YakPocGenerateResult>(
'yakit.poc.generate',
await capturedRequestEnginePayload(target, request.payload.id, false),
);
void appendAuditEvent({ category: 'capability', action: 'network.capture.generate_poc', outcome: 'success', targetTabId: target.tabId });
return ok(result);
}
case 'network.capture.analysis': {
const target = await requiredRequestTarget(request.payload, sender);
const result = await engineBridge.requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(target, request.payload.id, true),
);
void appendAuditEvent({ category: 'capability', action: 'network.capture.prepare_analysis', outcome: 'success', targetTabId: target.tabId });
return ok(result);
}
case 'observation.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const status = await startPageObservation(target, input);
void appendAuditEvent({
category: 'capability', action: 'observation.start', outcome: 'success', targetTabId: target.tabId,
summary: input.captureValues ? '包含用户明确启用的短时值预览' : '仅元数据',
});
return ok(status);
}
case 'observation.status': return ok(await pageObservationStatus(await requiredRequestTarget(request.payload, sender)));
case 'observation.list': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(await listPageObservations(target, request.payload.limit, true));
}
case 'observation.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await clearPageObservations(target);
void appendAuditEvent({ category: 'capability', action: 'observation.clear', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'observation.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await stopPageObservation(target);
void appendAuditEvent({ category: 'capability', action: 'observation.stop', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'audit.list': return ok(await listAuditEvents(request.payload.limit));
case 'audit.clear': {
await clearAuditEvents();
return ok();
}
case 'agent.runtime.get': return ok(await getAgentRuntime());
case 'agent.pause': {
const state = await getState();
if (!state.activeGrant) throw new ExtensionError('grant_expired', '没有可暂停的浏览器共享会话');
engineBridge.cancelActiveRequests();
const runtime = await setAgentRuntimeState('paused', state.activeGrant);
void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: state.activeGrant.taskId });
return ok(runtime);
}
case 'agent.resume': {
const state = await getState();
if (!state.activeGrant || state.activeGrant.expiresAt <= Date.now()) throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
const runtime = await setAgentRuntimeState('running', state.activeGrant);
void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: state.activeGrant.taskId });
return ok(runtime);
}
case 'agent.actions.clear': return ok(await clearAgentActions());
case 'policy.status': return ok(await getEnterprisePolicy());
case 'diagnostics.export': return ok(await createDiagnosticsBundle(engineBridge.getStatus()));
case 'metrics.get': return ok(await getRuntimeMetrics());
case 'metrics.reset': return ok(await resetRuntimeMetrics());
case 'bridge.config.save': {
const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy);
const state = await updateState((current) => ({ ...current, bridge: config }));
if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config);
else engineBridge.disconnect();
return ok(state);
}
case 'bridge.pair': {
const status = await engineBridge.startPairing();
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' });
return ok(status);
}
case 'bridge.pair.cancel': return ok(engineBridge.cancelPairing());
case 'bridge.pair.status': return ok(engineBridge.getPairingStatus());
case 'bridge.unpair': {
await engineBridge.unpair();
void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' });
return ok(await getState());
}
case 'bridge.connect': {
await engineBridge.connect();
void appendAuditEvent({ category: 'bridge', action: 'bridge.connect', outcome: 'success' });
return ok(engineBridge.getStatus());
}
case 'bridge.disconnect': {
engineBridge.disconnect();
void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' });
return ok(engineBridge.getStatus());
}
case 'bridge.status': return ok(engineBridge.getStatus());
default: return fail('未知扩展操作');
}
}
export async function runBackground(): Promise<void> {
recordServiceWorkerStart();
browser.runtime.onMessage.addListener((input: unknown, sender: Browser.runtime.MessageSender, sendResponse) => {
if (['bridge.status.changed', 'bridge.pairing.status.changed'].includes((input as { action?: string })?.action || '')) return undefined;
void Promise.resolve().then(() => parseExtensionRequest(input)).then((request) => handleRequest(request, sender)).then(sendResponse).catch((error) => sendResponse(fail(error)));
return true;
});
const storedState = await getState();
const state = applyPolicyToState(storedState, (await getEnterprisePolicy()).policy);
if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge) || JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) {
await updateState(() => state);
}
await applyUserAgentRules(state.userAgentRules).catch(console.error);
if (state.bridge.autoConnect && state.bridge.pairedEngine) await engineBridge.connect(state.bridge).catch(console.error);
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-385
View File
@@ -1,385 +0,0 @@
.proxy-container {
min-width: 200px;
background: white;
}
.proxy-menu {
border: none !important;
box-shadow: none !important;
overflow: hidden;
padding: 0 !important;
margin: 0 !important;
}
.menu-item {
height: 40px !important;
line-height: 40px !important;
margin: 0 !important;
padding: 0 16px !important;
}
.menu-item .anticon {
font-size: 16px;
color: var(--yakit-primary);
margin-right: 8px;
}
.menu-item-label {
font-size: 14px;
color: #333;
}
.menu-item:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item:hover .anticon,
.menu-item:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 选中状态 */
.menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
}
.menu-item.ant-menu-item-selected .anticon,
.menu-item.ant-menu-item-selected .menu-item-label {
color: white !important;
}
.menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
/* 分隔线 */
.ant-menu-item-divider {
margin: 4px 0 !important;
border-color: #EAECF3 !important;
}
/* 设置选项 */
.menu-item-setting {
border-top: 1px solid #EAECF3;
margin-top: 4px !important;
}
.menu-item-setting .anticon {
color: #666;
}
.menu-item-setting:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item-setting:hover .anticon,
.menu-item-setting:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 调整图标大小和对齐 */
.anticon {
font-size: 16px;
}
/* 添加以下样式来确保下拉菜单显示在正确的位置 */
.ant-dropdown {
position: absolute !important;
top: 100% !important;
left: 0 !important;
width: 100% !important;
min-width: 200px !important;
}
.dropdown-content {
background: white;
border-radius: 4px;
box-shadow: 0 3px 6px -4px rgba(0,0,0,0.12),
0 6px 16px 0 rgba(0,0,0,0.08),
0 9px 28px 8px rgba(0,0,0,0.05);
padding: 4px;
}
/* 确保容器不会限制弹出层 */
.proxy-container {
min-width: 200px;
background: white;
border-radius: 4px;
position: static;
}
/* 添加这个样式来确保下拉菜单显示在正确的位置 */
body {
position: relative;
}
.ant-menu {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
width: 100% !important;
background: white !important;
}
.ant-menu-item {
height: 36px !important;
line-height: 36px !important;
margin: 4px 8px !important;
padding: 0 16px !important;
display: flex !important;
align-items: center !important;
border-radius: 6px !important;
transition: all 0.2s ease-in-out !important;
}
.ant-menu-item:hover {
background-color: #f5f5f5 !important;
color: var(--yakit-primary) !important;
}
.ant-menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
border-radius: 6px !important;
}
.ant-menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
.ant-menu-item .anticon,
.ant-menu-item img {
font-size: 16px;
margin-right: 8px;
transition: all 0.2s ease-in-out !important;
}
.ant-menu-item.ant-menu-item-selected .anticon,
.ant-menu-item.ant-menu-item-selected img {
color: white;
}
.ant-menu-item-divider {
margin: 4px 0 !important;
height: 1px !important;
background-color: #f0f0f0 !important;
}
/* Checked item style (for the 2080 with checkmark) */
.ant-menu-item.checked::after {
content: "✓";
position: absolute;
right: 16px;
color: var(--yakit-primary);
font-weight: bold;
}
.ant-menu-item.ant-menu-item-selected.checked::after {
color: white;
}
/* Loading state */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
font-size: 14px;
color: var(--yakit-primary);
opacity: 0;
animation: fadeIn 0.2s ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Remove conflicting styles */
.panel-watermark {
display: none;
}
/* Improve bottom actions */
.ant-menu-item:nth-last-child(1),
.ant-menu-item:nth-last-child(2) {
border-top: 1px solid #f0f0f0;
margin-top: 4px !important;
}
.ant-menu-item:nth-last-child(1) .anticon,
.ant-menu-item:nth-last-child(2) .anticon {
color: #666;
}
.ant-menu-item:nth-last-child(1):hover .anticon,
.ant-menu-item:nth-last-child(2):hover .anticon {
color: var(--yakit-primary);
}
/* 全局样式重置,确保菜单项样式不受默认样式影响 */
.proxy-switch-container {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 0;
box-shadow: none;
}
/* 确保菜单没有边框和阴影 */
.proxy-switch-container .ant-menu {
border: none !important;
border-right: none !important;
box-shadow: none !important;
}
/* 确保所有菜单项正确对齐和布局 */
.proxy-switch-container .ant-menu-item {
margin: 4px 8px !important;
border-radius: 6px !important;
height: 36px !important;
line-height: 36px !important;
position: relative !important;
}
/* 图标对齐 */
.proxy-switch-container .ant-menu-item .anticon,
.proxy-switch-container .ant-menu-item img {
position: absolute !important;
left: 16px !important;
top: 50% !important;
transform: translateY(-50%) !important;
}
/* 选中态和悬停态 */
.proxy-switch-container .ant-menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
}
/* 选中态下的YAK图标变为白色 */
.proxy-switch-container .ant-menu-item.ant-menu-item-selected img {
filter: brightness(0) invert(1) !important;
}
.proxy-switch-container .ant-menu-item:hover {
background-color: #f5f5f5 !important;
}
.proxy-switch-container .ant-menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
/* 确保分割线样式 */
.proxy-switch-container .ant-menu-item-divider {
margin: 4px 0 !important;
height: 1px !important;
background-color: #f0f0f0 !important;
}
.panel-watermark {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
object-fit: contain;
object-position: right bottom;
z-index: 0;
}
/* 确保菜单项在水印上层 */
.ant-menu-item {
position: relative;
z-index: 1;
background: transparent !important;
}
/* 确保分割线在水印上层 */
.ant-menu-item-divider {
position: relative;
z-index: 1;
}
/* 为[直接连接]菜单项添加橙色背景,但仅在被选中时 */
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
}
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected .anticon,
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected span {
color: white !important;
}
/* 覆盖 Ant Design 的宽度计算,确保菜单项占据全宽 */
.proxy-switch-container .ant-menu .ant-menu-item,
.ant-menu-light .ant-menu-item,
.ant-menu-vertical .ant-menu-item,
.ant-menu-inline .ant-menu-item,
.ant-menu .ant-menu-item {
width: 100% !important;
margin-inline: 0 !important;
margin-block: 4px !important;
text-align: center !important;
height: 40px !important;
line-height: 40px !important;
padding-inline: 16px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
/* 让文本的容器也占满全宽 */
.proxy-switch-container .ant-menu .ant-menu-item .ant-menu-title-content,
.ant-menu .ant-menu-item .ant-menu-title-content {
display: block !important;
width: 100% !important;
text-align: center !important;
}
/* 活动项样式 */
.ant-menu .ant-menu-item.active-item::after {
content: "✓";
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--yakit-primary);
font-weight: bold;
}
.ant-menu .ant-menu-item.ant-menu-item-selected.active-item::after {
color: white;
}
.active-item {
background-color: var(--yakit-primary) !important;
color: white !important;
font-weight: bold;
transition: background-color 0.2s ease-in-out !important;
}
.active-item img {
filter: brightness(0) invert(1);
}
.active-item .anticon {
color: white !important;
}
.proxy-menu .ant-menu-item {
transition: all 0.2s ease-in-out !important;
}
/* 添加悬停效果 */
.proxy-menu .ant-menu-item:hover {
background-color: rgba(242, 139, 68, 0.1) !important;
}
-364
View File
@@ -1,364 +0,0 @@
import React, {useEffect, useState, useRef} from "react";
import {Menu} from "antd";
import {
DisconnectOutlined,
SettingOutlined,
PlusOutlined,
CheckOutlined,
} from "@ant-design/icons";
import {browser} from "wxt/browser";
import type {MenuProps} from "antd";
import type {ProxyConfig} from "@/types/proxy";
import {ContentActionType, ProxyActionType} from "@/types/action";
import {getAllProxyConfigs, getCurrentProxy} from "@/utils/storage";
import "./index.css";
// YAK 图标 URL
const YAK_ICON_URL = browser.runtime.getURL("/yak.svg");
// 固定的代理模式
const FIXED_MODES = [
{
key: "direct",
name: "[直接连接]",
icon: <DisconnectOutlined/>,
color: "#666",
config: {
id: "direct",
name: "[直接连接]",
proxyType: "direct",
enabled: false,
},
},
{
key: "system",
name: "[系统代理]",
icon: <SettingOutlined/>,
color: "#666",
config: {
id: "system",
name: "[系统代理]",
proxyType: "system",
enabled: false,
},
},
];
interface CustomProxy {
key: string;
name: string;
color: string;
config: ProxyConfig;
enabled?: boolean;
}
export const ProxySwitch: React.FC = () => {
const [initialized, setInitialized] = useState<boolean>(false);
const [currentMode, setCurrentMode] = useState<string>("direct"); // 默认选中直接连接
const [customProxies, setCustomProxies] = useState<CustomProxy[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const loadingTimeoutRef = useRef<number | null>(null);
// 监听存储变化
useEffect(() => {
const handleMessage = (message: any) => {
if (
message.action === ContentActionType.PROXY_CONFIGS_UPDATED &&
message.source !== "proxy_switch"
) {
console.log("proxy_switch 收到代理配置更新消息", message);
loadCustomProxies();
loadProxyStatus();
}
};
browser.runtime.onMessage.addListener(handleMessage);
return () => {
browser.runtime.onMessage.removeListener(handleMessage);
// 清除可能存在的超时计时器
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
};
}, []);
// 初始化
useEffect(() => {
let mounted = true;
const init = async () => {
try {
await loadProxyStatus();
if (mounted) {
await loadCustomProxies();
setInitialized(true);
}
} catch (error) {
console.error("初始化失败:", error);
if (mounted) {
setInitialized(true);
}
}
};
init();
return () => {
mounted = false;
};
}, []);
// 获取当前代理状态
const loadProxyStatus = async () => {
try {
// 先尝试从后台脚本获取当前代理状态
const response = await browser.runtime.sendMessage({
action: ProxyActionType.GET_PROXY_STATUS,
});
console.log("proxy_switch 获取当前代理状态", response);
if (response && response.success) {
const activeMode = response.data.mode;
console.log("获取到当前代理模式:", activeMode);
setCurrentMode(activeMode);
return;
}
// 如果后台脚本没有返回,则从存储中获取当前代理
const currentProxy = await getCurrentProxy();
if (currentProxy) {
console.log("从存储获取到当前代理:", currentProxy.id);
setCurrentMode(currentProxy.id);
} else {
console.log("未找到当前代理,使用默认值 direct");
setCurrentMode("direct");
}
} catch (error) {
console.error("Error loading proxy status:", error);
setCurrentMode("direct");
}
};
// 加载自定义代理配置
const loadCustomProxies = async () => {
try {
// 使用存储API获取所有代理配置
const configs = await getAllProxyConfigs();
// 处理代理配置
const proxies = configs
.filter(
(proxy: ProxyConfig) => !["direct", "system"].includes(proxy.id)
)
.map(
(proxy: ProxyConfig): CustomProxy => ({
key: proxy.id,
name: proxy.name,
color: "#1890ff",
config: proxy,
enabled: proxy.enabled,
})
);
setCustomProxies(proxies);
console.log("proxy_switch proxies", proxies);
// 查找并设置已启用的代理
const enabledProxy = configs.find((proxy: ProxyConfig) => proxy.enabled);
if (enabledProxy) {
console.log("proxy_switch enabledProxy", enabledProxy);
setCurrentMode(enabledProxy.id);
}
} catch (error) {
console.error("Error loading custom proxies:", error);
setCustomProxies([]);
}
};
// 处理代理模式变更
const handleModeChange = async (mode: string) => {
if (mode === "setting") {
// 打开设置页面
await browser.runtime.openOptionsPage?.();
return;
}
if (mode === "add") {
// 打开添加代理表单
try {
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const optionsUrl = browser.runtime.getURL("/options.html");
if (activeTab?.url === optionsUrl) {
browser.tabs.sendMessage(activeTab.id!, {
action: ContentActionType.TRIGGER_ADD_PROXY,
});
} else {
await browser.tabs.create({
url: optionsUrl,
});
}
} catch (error) {
console.error("Failed to get current tab:", error);
}
return;
}
console.log("proxy_switch 处理代理模式变更", mode);
// 如果当前已经是选中的模式,不做任何操作
if (mode === currentMode) return;
// 清除之前可能存在的加载超时
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
try {
// 先更新UI,让用户感知到变化
setCurrentMode(mode);
setIsLoading(true);
// 设置超时保护,确保加载状态最终会被清除
loadingTimeoutRef.current = window.setTimeout(() => {
setIsLoading(false);
}, 5000); // 5秒超时保护
// 发送切换代理请求
const response = await browser.runtime.sendMessage({
action: ProxyActionType.SWITCH_PROXY,
mode,
source: "proxy_switch",
});
// 请求完成后,清除超时保护
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = null;
}
if (!response || !response.success) {
// 如果失败,恢复原状态
console.error("Failed to switch proxy mode");
await loadProxyStatus(); // 重新加载正确的状态
} else {
console.log("代理模式切换成功:", mode);
// 不加 setTimeout firefox UI 会无法渲染选中状态
setTimeout(() => {
setCurrentMode((preMode) => {
return mode === preMode ? preMode : mode;
});
}, 20);
}
} catch (error) {
console.error(`Error switching to proxy mode ${mode}:`, error);
await loadProxyStatus(); // 出错时重新加载正确的状态
} finally {
// 无论如何,最终要关闭加载状态
setIsLoading(false);
}
};
// 构建菜单项
const buildMenuItems = () => {
const items: MenuProps["items"] = [
...FIXED_MODES.map((mode) => ({
key: mode.key,
label: mode.name,
icon: mode.icon,
className: `${currentMode === mode.key ? "active-item" : ""} menu-id-${
mode.key
}`,
})),
{type: "divider"},
];
// 添加自定义代理
if (customProxies.length > 0) {
items.push(
...customProxies.map((proxy) => {
// 构建提示信息:显示代理协议、主机和端口
const tooltipText =
proxy.config.proxyType === "fixed_servers" &&
proxy.config.host &&
proxy.config.port
? `${proxy.config.scheme || "http"}://${proxy.config.host}:${
proxy.config.port
}`
: proxy.config.proxyType === "pac_script"
? "PAC脚本代理"
: proxy.config.proxyType === "auto_detect"
? "自动检测代理"
: "";
// Firefox 兼容性:确保 active-item 类始终应用正确
const isActive = currentMode === proxy.key;
return {
key: proxy.key,
label: proxy.name,
icon: (
<img
src={YAK_ICON_URL}
alt="YAK"
style={{
width: 20,
height: 20,
filter: isActive ? "brightness(0) invert(1)" : "none",
transition: "filter 0.2s ease-in-out",
}}
/>
),
className: `${isActive ? "active-item" : ""} menu-id-${proxy.key}`,
title: tooltipText, // 添加悬停提示
};
})
);
items.push({type: "divider"});
}
// 添加设置选项
items.push({
key: "setting",
label: "代理设置",
icon: <SettingOutlined/>,
className: "menu-id-setting",
});
// 添加新建代理选项
items.push({
key: "add",
label: "添加代理",
icon: <PlusOutlined/>,
className: "menu-id-add",
});
return items;
};
// 只在加载完成后渲染内容
if (!initialized) {
return <div className="proxy-switch-container loading">加载中...</div>;
}
return (
<div className="proxy-switch-container">
<Menu
className="proxy-menu"
selectedKeys={[currentMode]}
defaultSelectedKeys={[currentMode]}
items={buildMenuItems()}
onClick={({key}) => handleModeChange(key)}
/>
{isLoading && (
<div className="loading-overlay">
切换中...
</div>
)}
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { cn } from '@/lib/cn';
export function YakMark({ className, alt = 'Yak' }: { className?: string; alt?: string }) {
return <img className={cn('yak-mark', className)} src="/yak.svg" alt={alt} />;
}
export function YakitMark({ className }: { className?: string }) {
return <img className={cn('yakit-mark', className)} src="/icon/yakitlogo.png" alt="Yakit" />;
}
export function ProductBrand({ compact = false, className }: { compact?: boolean; className?: string }) {
return (
<div className={cn('product-brand', compact && 'product-brand--compact', className)}>
<span className="product-brand__art"><YakMark /></span>
<span className="product-brand__copy">
<strong>Yakit Browser Agent</strong>
{!compact && <small>Authenticated browser security workspace</small>}
</span>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span className={cn('ui-badge', className)} {...props} />;
}
+31
View File
@@ -0,0 +1,31 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
const buttonVariants = cva('ui-button', {
variants: {
variant: {
primary: 'ui-button--primary',
secondary: 'ui-button--secondary',
ghost: 'ui-button--ghost',
danger: 'ui-button--danger',
},
size: {
sm: 'ui-button--sm',
md: 'ui-button--md',
icon: 'ui-button--icon',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
});
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
const Component = asChild ? Slot : 'button';
return <Component className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { LabelHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Field({ label, hint, children, className, ...props }: {
label: string;
hint?: string;
children: ReactNode;
} & Omit<LabelHTMLAttributes<HTMLLabelElement>, 'children'>) {
return (
<label className={cn('ui-field', className)} {...props}>
<span className="ui-field__label">{label}</span>
{children}
{hint && <small className="ui-field__hint">{hint}</small>}
</label>
);
}
+11
View File
@@ -0,0 +1,11 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root className={cn('ui-switch', className)} {...props}>
<SwitchPrimitive.Thumb className="ui-switch__thumb" />
</SwitchPrimitive.Root>
);
}
+17
View File
@@ -0,0 +1,17 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn('ui-tabs-list', className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
return <TabsPrimitive.Trigger className={cn('ui-tabs-trigger', className)} {...props} />;
}
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn('ui-tabs-content', className)} {...props} />;
}
+23
View File
@@ -0,0 +1,23 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export const TooltipProvider = TooltipPrimitive.Provider;
export function Tooltip({ label, children, side = 'top' }: {
label: string;
children: ReactNode;
side?: ComponentProps<typeof TooltipPrimitive.Content>['side'];
}) {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content side={side} sideOffset={6} className={cn('ui-tooltip')}>
{label}
<TooltipPrimitive.Arrow className="ui-tooltip__arrow" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { browser } from 'wxt/browser';
import { installPageWorldBridge } from '@/features/page-context/content-bridge';
import { isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, BridgeStatus, ExtensionState } from '@/types/models';
const PANEL_IDLE_UNLOAD_MS = 60_000;
const shellCss = `
:host { all: initial; position: fixed !important; inset: 0 !important; z-index: 2147483646 !important; pointer-events: none !important; }
.floating-panel { position: fixed; width: 46px; height: 46px; transform: translateY(-50%); pointer-events: auto; transition: width .16s ease; }
.floating-panel--left { left: 0; }
.floating-panel--right { right: 0; }
.floating-panel.is-expanded { width: min(326px, calc(100vw - 8px)); }
.floating-panel__header { position: absolute; top: 0; z-index: 2; width: 46px; height: 46px; touch-action: none; }
.floating-panel--left .floating-panel__header { left: 0; }
.floating-panel--right .floating-panel__header { right: 0; }
.floating-panel__brand { position: relative; width: 46px; height: 46px; padding: 0; display: grid; place-items: center; border: 1px solid #d7dce1; background: #fff; cursor: pointer; box-shadow: 0 8px 20px rgba(20,24,28,.18); transition: background-color .16s ease, box-shadow .16s ease; }
.floating-panel__brand:hover { background: #f1f3f5; }
:host([data-theme='dark']) .floating-panel__brand { border-color: #343a40; background: #1d232b; }
:host([data-theme='dark']) .floating-panel__brand:hover { background: #262d36; }
.floating-panel--left .floating-panel__brand { border-left: 0; border-radius: 0 23px 23px 0; }
.floating-panel--right .floating-panel__brand { border-right: 0; border-radius: 23px 0 0 23px; }
.floating-panel.is-expanded .floating-panel__brand { box-shadow: none; }
.floating-panel__brand:focus-visible { outline: 2px solid #ee7815; outline-offset: -3px; }
.floating-panel__brand img { width: 42px; height: 42px; display: block; object-fit: contain; pointer-events: none; }
.floating-panel__signal { position: absolute; right: 5px; bottom: 5px; width: 7px; height: 7px; border: 1px solid #fff; border-radius: 50%; background: #90979e; }
:host([data-theme='dark']) .floating-panel__signal { border-color: #1d232b; }
.floating-panel__signal.connected { background: #45b77d; }
.floating-panel__signal.connecting, .floating-panel__signal.negotiating { background: #e3a632; }
.floating-panel__signal.error { background: #dc5e5e; }
iframe { width: 100%; height: 320px; display: block; border: 0; border-radius: 8px; box-shadow: 0 10px 28px rgba(22,28,33,.18); }
`;
async function send<T>(action: string, payload?: unknown): Promise<T> {
const response = await browser.runtime.sendMessage({ action, payload }) as { ok?: boolean; data?: T; error?: string };
if (!response?.ok) throw new Error(response?.error || action);
return response.data as T;
}
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
runAt: 'document_start',
async main(ctx) {
if ((import.meta.env.FIREFOX && import.meta.env.MODE !== 'store')
|| (!import.meta.env.FIREFOX && import.meta.env.MODE !== 'production' && import.meta.env.MODE !== 'store')) {
await installPageWorldBridge(ctx).catch((error) => {
console.warn('[Yakit Browser Agent] MAIN-world bridge is unavailable; continuing without page Eval/Invoke.', error);
});
}
const host = document.createElement('yakit-browser-agent');
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = shellCss;
const panel = document.createElement('div');
panel.className = 'floating-panel floating-panel--right';
const header = document.createElement('div');
header.className = 'floating-panel__header';
const launcher = document.createElement('button');
launcher.type = 'button';
launcher.className = 'floating-panel__brand';
launcher.setAttribute('aria-label', '展开 Yakit Browser Agent');
const logo = document.createElement('img');
logo.src = browser.runtime.getURL('/yak.svg');
logo.alt = 'Yak';
logo.draggable = false;
const signal = document.createElement('span');
signal.className = 'floating-panel__signal disconnected';
launcher.append(logo, signal);
header.append(launcher);
panel.append(header);
shadow.append(style, panel);
document.documentElement.append(host);
// Launcher theme follows the extension appearance setting (settings.appearance.v1), falling back to the OS scheme.
const themeKey = 'settings.appearance.v1';
const applyTheme = (theme?: string) => {
host.dataset.theme = theme === 'light' || theme === 'dark'
? theme
: (globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
};
void browser.storage.local.get(themeKey).then((stored) => {
applyTheme((stored[themeKey] as { theme?: string } | undefined)?.theme);
});
let state: ExtensionState | undefined;
let currentTab: ActiveTabInfo | undefined;
let frame: HTMLIFrameElement | undefined;
let expanded = false;
let idleTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let drag: { pointerId: number; startX: number; startY: number; moved: boolean } | undefined;
const setBridgeStatus = (status: BridgeStatus) => {
signal.className = `floating-panel__signal ${status.state}`;
};
const siteAllowed = (next: ExtensionState) => {
const origin = location.origin;
if (next.floatingPanel.siteMode === 'allowlist') return next.floatingPanel.siteOrigins.includes(origin);
if (next.floatingPanel.siteMode === 'denylist') return !next.floatingPanel.siteOrigins.includes(origin);
return true;
};
const adjustForEdgeConflict = () => {
if (host.style.display === 'none') return;
const x = state?.floatingPanel.side === 'left' ? 8 : innerWidth - 8;
const desiredY = (state?.floatingPanel.y || 0.46) * innerHeight;
const previous = host.style.visibility;
host.style.visibility = 'hidden';
const behind = document.elementFromPoint(x, desiredY);
host.style.visibility = previous;
if (!behind) return;
const position = getComputedStyle(behind).position;
const bounds = behind.getBoundingClientRect();
if (!['fixed', 'sticky'].includes(position) || bounds.width < 32 || bounds.height < 32) return;
const offset = desiredY < innerHeight / 2 ? bounds.bottom + 30 : bounds.top - 30;
panel.style.top = `${Math.min(Math.max(offset / innerHeight, 0.08), 0.92) * 100}%`;
};
const applyState = (next: ExtensionState) => {
const previousHandoffId = state?.handoff?.state === 'waiting_for_user' ? state.handoff.id : undefined;
state = next;
const taskTargetsPage = Boolean(
next.activeGrant && next.activeGrant.expiresAt > Date.now()
&& currentTab && next.activeGrant.targets.some((target) => target.tabId === currentTab!.id),
);
const hasPageHandoff = Boolean(
next.handoff?.state === 'waiting_for_user' && next.handoff.target.tabId === currentTab?.id,
);
const visible = next.floatingPanel.enabled && siteAllowed(next)
&& (next.floatingPanel.displayMode === 'always' || taskTargetsPage || hasPageHandoff);
host.style.display = visible ? '' : 'none';
panel.classList.toggle('floating-panel--left', next.floatingPanel.side === 'left');
panel.classList.toggle('floating-panel--right', next.floatingPanel.side === 'right');
panel.style.top = `${next.floatingPanel.y * 100}%`;
if (!visible) collapse();
const nextHandoff = next.handoff?.state === 'waiting_for_user' && next.handoff.target.tabId === currentTab?.id
? next.handoff
: undefined;
if (nextHandoff && nextHandoff.id !== previousHandoffId) expand();
requestAnimationFrame(adjustForEdgeConflict);
};
const ensureFrame = () => {
if (frame) return;
frame = document.createElement('iframe');
frame.title = 'Yakit Browser Agent';
frame.src = `${browser.runtime.getURL('/floating.html')}?tabId=${currentTab?.id || ''}`;
panel.prepend(frame);
};
const unloadFrame = () => {
frame?.remove();
frame = undefined;
};
function collapse() {
expanded = false;
panel.classList.remove('is-expanded');
launcher.setAttribute('aria-label', '展开 Yakit Browser Agent');
if (idleTimer) globalThis.clearTimeout(idleTimer);
idleTimer = globalThis.setTimeout(unloadFrame, PANEL_IDLE_UNLOAD_MS);
}
const expand = () => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
ensureFrame();
expanded = true;
panel.classList.add('is-expanded');
launcher.setAttribute('aria-label', '收起 Yakit Browser Agent');
};
const [initialState, initialTab, initialBridge] = await Promise.all([
send<ExtensionState>('state.get'),
send<ActiveTabInfo>('tab.active').catch(() => undefined),
send<BridgeStatus>('bridge.status'),
]);
currentTab = initialTab;
applyState(initialState);
setBridgeStatus(initialBridge);
launcher.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
launcher.setPointerCapture(event.pointerId);
});
launcher.addEventListener('pointermove', (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4) drag.moved = true;
if (!drag.moved) return;
const side = event.clientX < innerWidth / 2 ? 'left' : 'right';
const y = Math.min(Math.max(event.clientY / innerHeight, 0.08), 0.92);
panel.classList.toggle('floating-panel--left', side === 'left');
panel.classList.toggle('floating-panel--right', side === 'right');
panel.style.top = `${y * 100}%`;
});
launcher.addEventListener('pointerup', (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const moved = drag.moved;
drag = undefined;
if (moved) {
const side = event.clientX < innerWidth / 2 ? 'left' : 'right';
const y = Math.min(Math.max(event.clientY / innerHeight, 0.08), 0.92);
void send<ExtensionState>('panel.update', { side, y }).then(applyState).catch(() => undefined);
} else if (expanded) collapse(); else expand();
});
const onStorageChange = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) void send<ExtensionState>('state.get').then(applyState).catch(() => undefined);
if (themeKey in changes) applyTheme((changes[themeKey] as { newValue?: { theme?: string } } | undefined)?.newValue?.theme);
};
const onRuntimeMessage = (message: unknown) => {
const input = message as { action?: string; payload?: BridgeStatus };
if (input?.action === 'bridge.status.changed' && input.payload) setBridgeStatus(input.payload);
};
const onFrameMessage = (event: MessageEvent) => {
const data = event.data as { channel?: string; type?: string; height?: number };
if (event.source !== frame?.contentWindow || data?.channel !== 'yakit-floating-host') return;
if (data.type === 'collapse') collapse();
if (data.type === 'resize' && typeof data.height === 'number' && frame) {
frame.style.height = `${Math.min(Math.max(Math.ceil(data.height), 160), Math.min(480, innerHeight - 16))}px`;
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (!state?.floatingPanel.shortcutEnabled || !event.altKey || !event.shiftKey || event.code !== 'KeyY') return;
if (host.style.display === 'none') return;
event.preventDefault();
if (expanded) collapse(); else expand();
};
const onFullscreenChange = () => {
if (state?.floatingPanel.autoCollapseFullscreen && document.fullscreenElement) collapse();
};
const onResize = () => requestAnimationFrame(adjustForEdgeConflict);
browser.storage.onChanged.addListener(onStorageChange);
browser.runtime.onMessage.addListener(onRuntimeMessage);
globalThis.addEventListener('message', onFrameMessage);
globalThis.addEventListener('keydown', onKeyDown, true);
document.addEventListener('fullscreenchange', onFullscreenChange);
globalThis.addEventListener('resize', onResize);
ctx.onInvalidated(() => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
browser.storage.onChanged.removeListener(onStorageChange);
browser.runtime.onMessage.removeListener(onRuntimeMessage);
globalThis.removeEventListener('message', onFrameMessage);
globalThis.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('fullscreenchange', onFullscreenChange);
globalThis.removeEventListener('resize', onResize);
host.remove();
});
},
});
+115
View File
@@ -0,0 +1,115 @@
html, body { width: 100%; height: 100%; margin: 0; pointer-events: none; }
.floating-panel {
position: fixed;
z-index: 2147483646;
width: 46px;
transform: translateY(-50%);
color: var(--foreground);
font-family: var(--font-sans);
font-size: var(--text-md);
letter-spacing: 0;
filter: drop-shadow(0 9px 20px rgba(20, 24, 28, .2));
transition: width .18s ease;
pointer-events: auto;
}
.floating-panel--left { left: 0; }
.floating-panel--right { right: 0; }
.floating-panel.is-expanded { width: min(326px, calc(100vw - 8px)); }
/* Header: follows theme surface, brand tile keeps the dark logo chip */
.floating-panel__header {
height: 46px;
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--foreground);
user-select: none;
touch-action: none;
}
.floating-panel--left .floating-panel__header { border-left: 0; border-radius: 0 8px 8px 0; }
.floating-panel--right .floating-panel__header { flex-direction: row-reverse; border-right: 0; border-radius: 8px 0 0 8px; }
.floating-panel.is-expanded .floating-panel__header { border-radius: 8px 8px 0 0; }
.floating-panel__brand { position: relative; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; }
.floating-panel__brand img { width: 42px; height: 42px; display: block; object-fit: contain; pointer-events: none; }
.floating-panel__signal { position: absolute; right: 5px; bottom: 5px; width: 7px; height: 7px; border: 1px solid var(--surface); border-radius: 50%; background: #90979e; }
.floating-panel__signal.connected { background: #45b77d; }
.floating-panel__signal.connecting { background: #e3a632; }
.floating-panel__signal.negotiating { background: #e3a632; }
.floating-panel__signal.error { background: #dc5e5e; }
.floating-panel__title { min-width: 0; flex: 1; display: grid; gap: 1px; padding: 0 10px; }
.floating-panel__title strong, .floating-panel__title span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-panel__title strong { color: var(--foreground); font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-panel__title span { color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.floating-panel__grip { color: var(--muted); }
.floating-panel__header > svg:last-child { margin: 0 10px 0 4px; color: var(--muted); }
.floating-panel__body {
overflow: hidden;
border: 1px solid var(--border-strong);
border-top: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-md);
}
.floating-tabs { width: auto; height: 34px; margin: 8px 10px 0; padding: 3px; display: grid; grid-template-columns: repeat(3, 1fr); border: 0; border-radius: 10px; background: var(--surface-subtle); }
.floating-tabs .ui-tabs-trigger { min-width: 0; height: 28px; display: flex; align-items: center; justify-content: center; gap: 5px; border-radius: 8px; font-size: var(--text-sm); }
.floating-tab-content { min-height: 208px; padding: 10px; display: grid; align-content: start; gap: 10px; }
.floating-section-heading { height: 28px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.floating-option-list { max-height: 224px; overflow-y: auto; display: grid; gap: 4px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.floating-option-list::-webkit-scrollbar { width: 8px; }
.floating-option-list::-webkit-scrollbar-track { background: transparent; }
.floating-option-list::-webkit-scrollbar-thumb { border-radius: 4px; background: var(--border-strong); }
.floating-option-list > button { width: 100%; min-height: 46px; padding: 6px 10px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; transition: background-color .13s ease; }
.floating-option-list > button:hover { background: var(--surface-subtle); }
.floating-option-list > button.is-active { background: var(--primary-soft); color: var(--primary-text); }
.floating-radio { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
.floating-option-list > button.is-active .floating-radio { border-color: var(--primary); background-color: var(--primary); }
.floating-option-list strong, .floating-option-list small { display: block; }
.floating-option-list > button > span { min-width: 0; }
.floating-option-list strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-option-list small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.floating-page-meta { min-width: 0; padding: 9px 12px; display: grid; gap: 3px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-page-meta strong, .floating-page-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-page-meta strong { font-size: var(--text-md); font-weight: 600; }
.floating-page-meta span { color: var(--muted); font-size: var(--text-sm); }
.floating-result { min-height: 34px; padding: 4px 6px 4px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-radius: var(--radius-md); background: var(--success-soft); color: var(--success); font-size: var(--text-sm); }
.floating-status-row { min-height: 54px; padding: 8px 10px; display: grid; grid-template-columns: 8px 1fr auto; gap: 9px; align-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
.floating-dot.connected { background: var(--success); }
.floating-dot.connecting { background: var(--warning); }
.floating-dot.negotiating { background: var(--warning); }
.floating-dot.error { background: var(--danger); }
.floating-status-row strong, .floating-status-row small { display: block; }
.floating-status-row strong { font-size: var(--text-md); font-weight: 600; }
.floating-status-row small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.floating-agent-task { min-height: 46px; padding: 8px 8px 8px 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; border-radius: var(--radius-md); background: var(--success-soft); }
.floating-agent-task.paused, .floating-agent-task.waiting_for_human { background: var(--warning-soft); }
.floating-agent-task strong, .floating-agent-task small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-agent-task strong { color: var(--success); font-size: var(--text-md); font-weight: 600; }
.floating-agent-task.paused strong, .floating-agent-task.waiting_for_human strong { color: var(--warning); }
.floating-agent-task small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); }
.floating-share-row { min-height: 54px; padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-share-row strong, .floating-share-row small { display: block; }
.floating-share-row strong { font-size: var(--text-md); font-weight: 600; }
.floating-share-row small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); }
.floating-handoff { min-height: 112px; padding: 12px; display: grid; align-content: space-between; gap: 12px; border: 1px solid color-mix(in srgb, var(--warning) 30%, var(--surface)); border-radius: var(--radius-md); background: var(--warning-soft); }
.floating-handoff__copy { min-width: 0; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; }
.floating-handoff__copy > svg { margin-top: 1px; color: var(--warning); }
.floating-handoff__copy strong, .floating-handoff__copy small { display: block; }
.floating-handoff__copy strong { color: var(--warning); font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-handoff__copy small { margin-top: 4px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; overflow-wrap: anywhere; }
.floating-handoff__actions { display: grid; grid-template-columns: 1fr auto; gap: 6px; }
.floating-notice { margin: 0 10px 10px; padding: 8px 12px; border-radius: var(--radius-md); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); line-height: 1.45; }
.spin { animation: floating-spin .8s linear infinite; }
@keyframes floating-spin { to { transform: rotate(360deg); } }
+2 -69
View File
@@ -1,73 +1,6 @@
import { browser, type Browser } from 'wxt/browser';
import {ContentActionType, ProxyActionType} from '@/types/action';
import { getCurrentProxyMode, switchProxyMode } from '@/utils/proxy';
import { getProxyConfig, saveProxyConfig } from '@/utils/storage';
import type { ProxyConfig } from '@/types/proxy';
// 固定的代理模式配置
const FIXED_MODES = [
{
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
},
{
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: false
}
];
import { runBackground } from '@/app/background';
export default defineBackground({
type: 'module',
async main() {
// 初始化固定模式的代理配置
await initializeFixedModes();
// 初始化代理状态监听
browser.runtime.onMessage.addListener((message: any, sender: Browser.runtime.MessageSender, sendResponse: (response?: any) => void) => {
if (message.action === ProxyActionType.GET_PROXY_STATUS) {
// 获取当前代理状态
getCurrentProxyMode().then(mode => {
sendResponse({ success: true, data: { mode } });
});
return true;
} else if (message.action === ProxyActionType.SWITCH_PROXY) {
// 切换代理
switchProxyMode(message.mode).then(success => {
sendResponse({ success });
// 如果切换成功,广播代理状态更改消息
if (success) {
browser.runtime.sendMessage({
action: ContentActionType.PROXY_CONFIGS_UPDATED,
source: 'background'
});
}
});
return true;
}
});
console.log('代理管理后台服务已启动');
},
main: runBackground,
});
// 初始化固定模式的代理配置
async function initializeFixedModes() {
try {
// 确保固定模式的配置已保存到数据库
for (const modeConfig of FIXED_MODES) {
const existingConfig = await getProxyConfig(modeConfig.id);
if (!existingConfig) {
console.log(`初始化固定模式配置: ${modeConfig.id}`);
await saveProxyConfig(modeConfig as ProxyConfig);
}
}
} catch (error) {
console.error('初始化固定模式配置失败:', error);
}
}
-6
View File
@@ -1,6 +0,0 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.');
},
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yakit Browser Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { browser } from 'wxt/browser';
import { TooltipProvider } from '@/components/ui/tooltip';
import { FloatingPanel } from '@/features/floating-panel/FloatingPanel';
import type { ActiveTabInfo, BridgeStatus, ExtensionState } from '@/types/models';
import { request } from '@/platform/messaging/runtime';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css';
import '../agent.content/style.css';
import './style.css';
watchTheme();
function FloatingApp() {
const [initial, setInitial] = useState<{ state: ExtensionState; tab?: ActiveTabInfo; bridge: BridgeStatus }>();
const [error, setError] = useState('');
useEffect(() => {
const tabId = Number(new URLSearchParams(location.search).get('tabId'));
void Promise.all([
request('state.get'),
Number.isSafeInteger(tabId) && tabId > 0
? request('tab.get', { tabId }).catch(() => undefined)
: Promise.resolve(undefined),
request('bridge.status'),
]).then(([state, tab, bridge]) => setInitial({ state, tab, bridge }))
.catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
}, []);
if (error) return <div className="floating-frame-error">{error}</div>;
if (!initial) return <div className="floating-frame-loading">正在加载</div>;
return (
<FloatingPanel
initialState={initial.state}
initialTab={initial.tab}
initialBridge={initial.bridge}
yakIconUrl={browser.runtime.getURL('/yak.svg')}
embedded
/>
);
}
createRoot(document.getElementById('app')!).render(
<TooltipProvider delayDuration={350}><FloatingApp /></TooltipProvider>,
);
+8
View File
@@ -0,0 +1,8 @@
html, body, #app { width: 100%; height: 100%; min-width: 0; min-height: 0; margin: 0; overflow: hidden; }
body { background: transparent; }
.floating-panel.floating-panel--embedded { position: relative; inset: auto; width: 100%; height: 100%; transform: none; filter: none; }
.floating-panel--embedded .floating-panel__header { border-radius: 8px 8px 0 0; }
.floating-panel--embedded .floating-panel__body { max-height: calc(100% - 46px); overflow: auto; box-shadow: none; }
.floating-panel--embedded .floating-panel__brand { visibility: hidden; }
.floating-frame-loading, .floating-frame-error { height: 100%; display: grid; place-items: center; padding: 16px; box-sizing: border-box; color: var(--muted); background: var(--surface); font: 13px var(--font-sans); }
.floating-frame-error { color: var(--danger); }
+614 -71
View File
@@ -1,85 +1,628 @@
.options-layout {
min-height: 100vh;
}
/* Options 工作台 —— 基于 src/styles/tokens.css 令牌,暗色由 html[data-theme='dark'] 自动切换 */
.options-header {
background-color: #F28B44;
display: flex;
code, pre { font-family: var(--font-mono); }
pre { margin: 0; }
input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; accent-color: var(--primary); }
/* ---------- 原生按钮(组件库之外的 <button>) ---------- */
.primary-button, .danger-button, .icon-button,
.page-heading > button:not(.ui-button),
.editor-actions > button:not(.ui-button),
.panel-title > button:not(.ui-button) {
min-height: 36px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 24px;
height: 64px;
gap: 7px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--foreground);
font-size: var(--text-md);
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.page-heading > button:not(.ui-button):hover,
.editor-actions > button:not(.ui-button):hover,
.panel-title > button:not(.ui-button):hover { border-color: var(--muted); background: var(--surface-subtle); }
.primary-button { border-color: var(--primary-strong); background: var(--primary-strong); color: var(--primary-on-strong); }
.primary-button:hover { border-color: var(--primary-strong-hover); background: var(--primary-strong-hover); }
.danger-button { border-color: color-mix(in srgb, var(--danger) 35%, var(--surface)); color: var(--danger); }
.danger-button:hover { background: var(--danger-soft); }
.icon-button { width: 34px; height: 34px; min-height: 34px; padding: 0; }
.icon-button:hover { background: var(--surface-subtle); }
.icon-button.danger { color: var(--danger); }
.icon-button.danger:hover { background: var(--danger-soft); }
.primary-button:disabled, .danger-button:disabled, .icon-button:disabled,
.page-heading > button:not(.ui-button):disabled,
.editor-actions > button:not(.ui-button):disabled { border-color: var(--border); background: var(--surface-subtle); color: var(--muted); cursor: not-allowed; }
.primary-button:focus-visible, .danger-button:focus-visible, .icon-button:focus-visible,
.page-heading > button:not(.ui-button):focus-visible,
.editor-actions > button:not(.ui-button):focus-visible,
.panel-title > button:not(.ui-button):focus-visible,
.data-row:focus-visible, .network-row:focus-visible, .observation-row:focus-visible,
.task-workflow-list button:focus-visible, .context-node-list button:focus-visible,
.sidebar nav button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus);
}
.options-content {
padding: 32px;
min-width: 600px;
margin: 0 auto;
background-color: #f5f5f5;
}
/* ---------- 布局骨架 ---------- */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 238px minmax(0, 1fr); }
/* 所有单列纵向 grid 容器必须显式 minmax(0,1fr),否则子元素 max-content 会撑破窄屏 */
.content-area, .section-view, .settings-form, .list-pane, .editor-pane, .rule-editor,
.pairing-workspace, .panel-policy-settings, .grant-editor, .protocol-panel,
.observation-section, .network-inspector, .context-primary, .context-inspector,
.context-inspector > section, .context-diff, .context-inventory, .context-node-browser,
.context-mode, .context-json, .context-utility-panel, .tab-picker, .tab-picker-group, .data-list,
.task-workflow-list, .cookie-transfer, .network-artifact { grid-template-columns: minmax(0, 1fr); }
.workspace { min-width: 0; position: relative; }
.content-area { max-width: 1440px; margin: 0 auto; padding: 22px 28px 36px; display: grid; gap: 16px; }
.workspace-loading { min-height: 100vh; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: var(--text-md); }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .35; } }
.proxy-list-card {
margin-bottom: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* ---------- 侧栏(与全局表面一致,暗色主题随令牌切换) ---------- */
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--surface); color: var(--foreground); }
.sidebar-brand { height: 64px; padding: 0 14px; display: flex; align-items: center; border-bottom: 1px solid var(--border); }
.sidebar-brand .product-brand { width: 100%; color: var(--foreground); }
.sidebar nav { padding: 14px 10px; display: grid; gap: 2px; }
.sidebar nav button { width: 100%; height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px 1fr 14px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-strong); font-size: var(--text-md); font-weight: 500; text-align: left; cursor: pointer; transition: background-color .14s ease, color .14s ease; }
.sidebar nav button:hover { background: var(--surface-subtle); color: var(--foreground); }
.sidebar nav button.active { background: var(--surface-subtle); color: var(--foreground); box-shadow: inset 3px 0 0 var(--primary); }
.sidebar nav button.active svg:first-child { color: var(--primary); }
.sidebar nav button > svg:last-child { opacity: 0; }
.sidebar nav button.active > svg:last-child { opacity: 1; }
.sidebar-theme { margin-top: auto; padding: 12px 14px; display: grid; gap: 7px; border-top: 1px solid var(--border); }
.sidebar-theme > span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.sidebar-theme select { height: 34px; }
.sidebar-status { min-height: 64px; padding: 12px 14px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: center; border-top: 1px solid var(--border); }
.sidebar-yakit-mark { position: relative; width: 28px; height: 28px; }
.sidebar-yakit-mark .yakit-mark { width: 28px; height: 28px; border-radius: 6px; }
.sidebar-status strong, .sidebar-status span { display: block; }
.sidebar-status strong { font-size: var(--text-sm); line-height: 17px; }
.sidebar-status div > span { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: var(--text-xs); line-height: 14px; white-space: nowrap; text-overflow: ellipsis; }
.connection-dot { position: absolute; right: -2px; bottom: -2px; width: 10px; height: 10px; border: 2px solid var(--surface); border-radius: 50%; background: var(--muted); }
.connection-dot.connected { background: #45b981; }
.connection-dot.connecting, .connection-dot.negotiating { background: #e3a632; animation: pulse 1.3s infinite; }
.connection-dot.error { background: #e06e6e; }
.proxy-list-card .ant-card-head {
padding: 0 16px;
min-height: 48px;
}
/* ---------- 顶栏 ---------- */
.topbar { position: sticky; top: 0; z-index: 5; height: 60px; padding: 0 max(28px, (100% - 1440px) / 2 + 28px); display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); background: var(--background); }
.topbar-tab { min-width: 0; flex: 0 1 420px; height: 38px; padding: 0 6px 0 11px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-sm); }
.topbar-tab__favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
.topbar-tab__favicon img { width: 16px; height: 16px; object-fit: contain; }
.target-tab-select { min-width: 0; flex: 1; height: 36px; padding: 0 4px; border: 0; background: transparent; font-size: var(--text-md); }
.target-tab-select:focus-visible { box-shadow: none; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.proxy-list-card .ant-card-head-title {
padding: 14px 0;
font-size: 16px;
}
.proxy-list-card .ant-card-head-wrapper {
display: flex;
/* ---------- 状态徽章 ---------- */
.permission-state, .large-status, .agent-runtime-state, .capture-state {
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted-strong);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
}
.permission-state.enabled, .large-status.connected, .agent-runtime-state.running {
border-color: color-mix(in srgb, var(--success) 38%, var(--surface));
background: var(--success-soft);
color: var(--success);
}
.large-status.connecting, .large-status.negotiating, .agent-runtime-state.paused, .agent-runtime-state.waiting_for_human {
border-color: color-mix(in srgb, var(--warning) 42%, var(--surface));
background: var(--warning-soft);
color: var(--warning);
}
.large-status.error, .agent-runtime-state.revoked, .agent-runtime-state.expired {
border-color: color-mix(in srgb, var(--danger) 38%, var(--surface));
background: var(--danger-soft);
color: var(--danger);
}
.capture-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
.capture-state.active { border-color: color-mix(in srgb, var(--success) 38%, var(--surface)); background: var(--success-soft); color: var(--success); }
.capture-state.active i { background: var(--success); animation: pulse 1.4s infinite; }
/* ---------- 页面通用 ---------- */
.section-view { display: grid; gap: 16px; align-content: start; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.page-heading h1 { margin: 0; font-size: var(--text-2xl); font-weight: 700; line-height: 28px; }
.page-heading p { margin: 5px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 17px; }
.section-view h2 { margin: 0; font-size: var(--text-lg); font-weight: 650; }
.empty-state { min-height: 130px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border-radius: var(--radius-md); color: var(--muted); font-size: var(--text-md); text-align: center; }
.status-good { color: var(--success); font-weight: 600; }
.status-error { color: var(--danger); font-weight: 600; }
.status-muted { color: var(--muted); }
.active-label { padding: 2px 7px; border-radius: 999px; background: var(--primary-soft); color: var(--primary-text); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; }
/* 代码/报文块 —— 浅色主题用浅灰嵌底,暗色主题用深面板 */
.network-packet, .invoke-result, .network-artifact pre, .context-json pre,
.proxy-tools pre, .observation-values pre, .observation-stack pre {
margin: 0;
padding: 12px 13px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
color: var(--foreground);
font-size: var(--text-sm);
line-height: 1.55;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
[data-theme='dark'] .network-packet, [data-theme='dark'] .invoke-result, [data-theme='dark'] .network-artifact pre,
[data-theme='dark'] .context-json pre, [data-theme='dark'] .proxy-tools pre,
[data-theme='dark'] .observation-values pre, [data-theme='dark'] .observation-stack pre {
border-color: #262c33;
background: #12161b;
color: #d6dde4;
}
.proxy-list-card .ant-card-extra {
padding: 8px 0;
/* Toast */
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 30; max-width: 420px; display: flex; align-items: center; gap: 8px; padding: 11px 15px; border-radius: var(--radius-md); box-shadow: var(--shadow-md); font-size: var(--text-md); font-weight: 500; }
.toast.ok { border: 1px solid color-mix(in srgb, var(--success) 38%, var(--surface)); background: var(--success-soft); color: var(--success); }
.toast.error { border: 1px solid color-mix(in srgb, var(--danger) 38%, var(--surface)); background: var(--danger-soft); color: var(--danger); }
/* 人工接管横幅 */
.handoff-banner { padding: 14px 18px; display: flex; align-items: center; gap: 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-lg); background: var(--warning-soft); box-shadow: var(--shadow-sm); }
.handoff-banner > svg { flex: 0 0 auto; color: var(--warning); }
.handoff-banner__copy { min-width: 0; flex: 1; }
.handoff-banner__copy span, .handoff-banner__copy strong, .handoff-banner__copy small { display: block; }
.handoff-banner__copy span { color: var(--warning); font-size: var(--text-sm); font-weight: 650; }
.handoff-banner__copy strong { margin-top: 2px; font-size: var(--text-md); line-height: 18px; overflow-wrap: anywhere; }
.handoff-banner__copy small { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.handoff-banner__actions { display: flex; gap: 8px; }
/* ---------- 运行概览 ---------- */
.task-command-bar { padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.task-site-identity { min-width: 0; display: flex; align-items: center; gap: 11px; }
.task-site-identity > svg { flex: 0 0 auto; color: var(--primary); }
.task-site-identity strong, .task-site-identity small { display: block; }
.task-site-identity strong { font-size: var(--text-md); font-weight: 650; line-height: 18px; }
.task-site-identity small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.task-quick-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.task-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.task-status-grid section { min-width: 0; padding: 15px 16px 12px; display: grid; gap: 3px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.task-status-grid section.needs-attention { box-shadow: inset 3px 0 0 var(--warning), var(--shadow-sm); }
.task-status-grid span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.task-status-grid strong { margin-top: 4px; overflow: hidden; font-size: var(--text-lg); font-weight: 650; line-height: 19px; white-space: nowrap; text-overflow: ellipsis; }
.task-status-grid small { min-height: 32px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.task-status-grid button { margin: 8px -6px 0; padding: 4px 6px; display: flex; align-items: center; justify-content: space-between; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--primary-text); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
.task-status-grid button:hover { background: var(--primary-soft); }
.task-workflow-list { display: grid; gap: 10px; }
.task-workflow-list button { min-height: 62px; padding: 10px 16px; display: grid; grid-template-columns: 22px minmax(0, 1fr) 16px; gap: 13px; align-items: center; border: 0; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .14s ease; }
.task-workflow-list button:hover { background: var(--surface-subtle); }
.task-workflow-list button > svg:first-child { color: var(--muted-strong); }
.task-workflow-list button:hover > svg:first-child { color: var(--primary); }
.task-workflow-list button > svg:last-child { color: var(--muted); }
.task-workflow-list strong, .task-workflow-list small { display: block; }
.task-workflow-list strong { font-size: var(--text-md); font-weight: 650; line-height: 18px; }
.task-workflow-list small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
/* ---------- 操作记录 ---------- */
.activity-view .activity-heading-actions, .network-heading-actions { display: flex; align-items: center; gap: 8px; }
.agent-runtime-band { padding: 15px 18px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.agent-runtime-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 18px; align-items: center; }
.agent-runtime-summary span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.agent-runtime-summary strong, .agent-runtime-summary small { display: block; }
.agent-runtime-summary strong { margin-top: 4px; overflow: hidden; font-size: var(--text-lg); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.agent-runtime-summary small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.agent-runtime-controls { display: flex; gap: 8px; }
.agent-action-list { margin-top: 14px; display: grid; border-top: 1px solid var(--border); }
.agent-action-row { padding: 8px 2px; display: grid; grid-template-columns: 12px 84px minmax(160px, 1.4fr) minmax(80px, .6fr) 110px 76px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); font-size: var(--text-sm); }
.agent-action-row code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.agent-action-row strong { font-size: var(--text-sm); }
.agent-action-row strong.success { color: var(--success); }
.agent-action-row strong.error { color: var(--danger); }
.agent-actions-empty { margin-top: 14px; padding: 14px 4px 2px; color: var(--muted); font-size: var(--text-sm); }
.action-state { width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
.action-state.success { background: var(--success); }
.action-state.error { background: var(--danger); }
.action-state.running { background: var(--primary); animation: pulse 1.2s infinite; }
.activity-subheading { margin-top: 6px; display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.activity-subheading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); }
.activity-loading { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: var(--text-md); }
.activity-loading.error { color: var(--danger); }
.activity-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.activity-table__head, .activity-table__row { padding: 0 16px; display: grid; grid-template-columns: 150px 86px minmax(150px, 1.1fr) minmax(150px, 1.2fr) 88px 72px; gap: 12px; align-items: center; }
.activity-table__head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.activity-table__row { min-height: 42px; border-bottom: 1px solid var(--border); font-size: var(--text-sm); }
.activity-table__row:last-child { border-bottom: 0; }
.activity-table__row > * { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.activity-table__row code { font-size: var(--text-sm); }
.audit-outcome.success { color: var(--success); font-weight: 600; }
.audit-outcome.error { color: var(--danger); font-weight: 600; }
/* ---------- 分栏编辑页(代理配置 / 代理规则 / UA / Cookie) ---------- */
.split-view { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start; }
.split-view, .rule-layout { display: grid; }
.rule-layout { grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.list-pane, .editor-pane { min-width: 0; display: grid; gap: 14px; align-content: start; }
.editor-pane { padding: 18px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.editor-heading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); word-break: break-all; }
.data-list { display: grid; gap: 8px; }
.data-row { min-height: 58px; padding: 8px 12px; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto auto 15px; gap: 10px; align-items: center; border: 0; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--foreground); text-align: left; cursor: pointer; }
.data-row:hover { background: var(--surface-subtle); }
.data-row.selected { box-shadow: inset 3px 0 0 var(--primary), var(--shadow-sm); }
.data-row > svg:last-child { color: var(--muted); }
.data-row strong, .data-row small { display: block; }
.data-row strong { font-size: var(--text-md); font-weight: 600; }
.data-row small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.row-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted-strong); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.form-grid .ui-field:has(textarea), .form-grid .check-row { grid-column: 1 / -1; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: var(--text-md); }
.editor-actions { display: flex; gap: 8px; }
.rule-editor { min-width: 0; padding: 18px; display: grid; gap: 13px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.rule-editor > h2 { margin-bottom: 2px; }
.rule-editor > p { margin: -4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* 代理规则 */
.proxy-routing-bar { padding: 15px 18px; display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.proxy-routing-bar .ui-field { width: 200px; }
.proxy-preview-input { min-width: 0; flex: 1; display: grid; gap: 6px; }
.proxy-preview-input > label { color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; }
.proxy-preview-input > div { display: flex; gap: 6px; align-items: center; }
.proxy-preview-result { min-width: 180px; padding: 9px 13px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.proxy-preview-result.conflict { background: var(--warning-soft); }
.proxy-preview-result small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.proxy-preview-result strong { font-size: var(--text-md); font-weight: 650; }
.proxy-preview-result span { color: var(--muted); font-size: var(--text-sm); }
.proxy-preview-result i { color: var(--warning); font-size: var(--text-sm); font-style: normal; font-weight: 600; }
.rule-table, .proxy-rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.table-head, .table-row { padding: 0 16px; display: grid; gap: 12px; align-items: center; }
.table-head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.table-row { min-height: 46px; border-bottom: 1px solid var(--border); font-size: var(--text-md); }
.table-row:last-child { border-bottom: 0; }
.table-row > * { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.table-row code { font-size: var(--text-sm); }
.rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(110px, 1fr) minmax(180px, 2fr) minmax(110px, 1fr) 64px 34px; }
.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: 20px minmax(150px, 1.3fr) minmax(130px, 1fr) 100px 54px 62px 34px; }
.proxy-rule-table .table-row { cursor: grab; }
.proxy-rule-table .table-row > svg { color: var(--muted); }
.proxy-rule-name { padding: 0; display: block; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
.proxy-rule-name:hover strong { color: var(--primary-text); }
.proxy-rule-name strong, .proxy-rule-name small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.proxy-rule-name strong { font-size: var(--text-md); font-weight: 600; }
.proxy-rule-name small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.proxy-tools { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.proxy-tools > section { min-width: 0; padding: 15px 16px; display: grid; gap: 11px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.proxy-tools > section > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.proxy-tools pre { max-height: 220px; }
.proxy-tools textarea { min-height: 160px; font-family: var(--font-mono); font-size: var(--text-sm); }
.proxy-stats p { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--text-md); }
.proxy-stats > span { color: var(--muted); font-size: var(--text-sm); }
/* Cookie Editor */
.url-bar { display: flex; align-items: center; gap: 12px; }
.url-bar input { flex: 1; }
.url-bar > span { flex: 0 0 auto; color: var(--muted); font-size: var(--text-sm); }
.cookie-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cookie-toolbar select { width: auto; min-width: 108px; }
.cookie-toolbar .ui-button { margin-left: auto; }
.network-search { position: relative; min-width: 200px; flex: 1; }
.network-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.network-search input { padding-left: 31px; }
.cookie-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.cookie-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.cookie-columns { padding: 0 14px; display: grid; grid-template-columns: 24px minmax(120px, 1fr) minmax(150px, 1.2fr) minmax(120px, .9fr) minmax(110px, .8fr) 34px; gap: 10px; align-items: center; }
.cookie-group__heading { padding: 8px 14px 5px; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
.cookie-group__heading span { font-weight: 500; text-transform: none; }
.cookie-name-button { padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
.cookie-name-button strong { display: block; overflow: hidden; font-size: var(--text-md); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
.cookie-name-button:hover strong { color: var(--primary-text); }
.cookie-value-button { min-width: 0; padding: 3px 6px; display: flex; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); cursor: pointer; }
.cookie-value-button:hover { background: var(--surface-subtle); }
.cookie-value-button code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.cookie-value-button svg { flex: 0 0 auto; color: var(--muted); }
.cookie-columns > span > small { display: block; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 15px; white-space: nowrap; text-overflow: ellipsis; }
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
.tag-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; }
.cookie-editor-pane { position: sticky; top: 76px; }
.secret-field { position: relative; }
.secret-field .ui-button--icon { position: absolute; right: 6px; top: 6px; width: 28px; height: 28px; }
.secret-field.masked textarea { -webkit-text-security: disc; }
.cookie-transfer { display: grid; gap: 10px; }
.cookie-transfer .segmented { justify-self: start; }
.transfer-status { color: var(--muted); font-size: var(--text-sm); }
/* 分段选择器 */
.segmented { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-subtle); }
.segmented button { min-width: 72px; height: 30px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
.segmented button.active { background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-sm); }
.segmented button:disabled { opacity: .45; cursor: not-allowed; }
/* ---------- 网络活动 ---------- */
.network-control-bar { padding: 10px 18px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.network-control-bar > label { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.network-control-bar > label > span { display: block; }
.network-control-bar strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.network-control-bar small { display: block; margin-top: 1px; color: var(--muted); font-size: var(--text-sm); }
.network-control-bar .network-search { flex: 1; min-width: 180px; }
.network-error { padding: 12px 16px; display: flex; align-items: center; gap: 9px; border-radius: var(--radius-lg); background: var(--danger-soft); color: var(--danger); font-size: var(--text-md); }
.network-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 16px; align-items: start; }
.network-timeline { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.network-table-head { padding: 0 16px; min-height: 38px; display: grid; grid-template-columns: 62px 58px minmax(0, 1fr) 88px 72px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.network-row { width: 100%; padding: 9px 16px; display: grid; grid-template-columns: 62px 58px minmax(0, 1fr) 88px 72px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.network-row:last-child { border-bottom: 0; }
.network-row:hover { background: var(--surface-subtle); }
.network-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.network-row > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.method { font-size: var(--text-sm); font-weight: 700; }
.method-get { color: var(--success); }
.method-post { color: var(--primary-text); }
.method-put, .method-patch { color: var(--warning); }
.method-delete { color: var(--danger); }
.network-target strong, .network-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.network-target strong { font-size: var(--text-md); font-weight: 600; }
.network-target small { margin-top: 1px; color: var(--muted); font-size: var(--text-sm); }
.network-inspector { min-width: 0; padding: 16px; display: grid; gap: 14px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); position: sticky; top: 76px; }
.network-inspector__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.network-inspector__heading > div { min-width: 0; }
.network-inspector__heading > div > span { color: var(--muted); font-size: var(--text-xs); font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.network-inspector__heading strong, .network-inspector__heading small { display: block; overflow: hidden; text-overflow: ellipsis; }
.network-inspector__heading strong { margin-top: 3px; font-size: var(--text-lg); font-weight: 650; word-break: break-all; }
.network-inspector__heading small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }
.network-meta { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
.network-meta > div { min-width: 0; }
.network-meta dt { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.network-meta dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.network-packet-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.network-packet-heading > strong { font-size: var(--text-md); font-weight: 650; }
.network-packet-heading > div { display: flex; gap: 6px; align-items: center; }
.network-packet { max-height: 320px; white-space: pre; }
.network-limitations { padding: 9px 12px; display: flex; gap: 8px; align-items: flex-start; border-radius: var(--radius-md); background: var(--warning-soft); color: var(--warning); font-size: var(--text-sm); line-height: 1.5; }
.network-preview-empty { padding: 18px 14px; display: flex; align-items: flex-start; gap: 9px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); line-height: 1.55; }
.network-preview-empty svg { flex: 0 0 auto; margin-top: 1px; }
.network-artifact { display: grid; gap: 8px; }
.network-artifact > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.network-artifact strong { font-size: var(--text-md); font-weight: 650; }
.network-artifact pre { max-height: 260px; }
/* 页面行为观测 */
.observation-section { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.observation-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.observation-heading span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.observation-heading h2 { margin-top: 3px; }
.observation-controls { padding: 0; box-shadow: none; }
.observation-kinds { margin-left: auto; color: var(--muted); font-size: var(--text-sm); }
.observation-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; }
.observation-timeline { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
.observation-table-head { padding: 0 13px; min-height: 34px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.observation-row { width: 100%; padding: 8px 13px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.observation-row:last-child { border-bottom: 0; }
.observation-row:hover { background: var(--surface-subtle); }
.observation-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.observation-row > strong { overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.observation-row > span, .observation-row > time { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.observation-target strong, .observation-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.observation-target strong { font-weight: 600; }
.observation-target small { margin-top: 1px; color: var(--muted); }
.observation-inspector { position: static; padding: 0; box-shadow: none; }
.observation-values, .observation-stack { display: grid; gap: 7px; }
.observation-values > strong, .observation-stack > strong { font-size: var(--text-sm); font-weight: 650; }
.observation-values pre, .observation-stack pre { max-height: 180px; }
/* ---------- 登录态工作区 ---------- */
.context-options { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.context-options select { width: auto; min-width: 240px; }
.context-options > span { overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-mode { display: grid; gap: 16px; }
.context-mode-tabs { justify-self: start; }
.context-empty { min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--muted); }
.context-empty svg { color: var(--border-strong); }
.context-empty strong { color: var(--muted-strong); font-size: var(--text-lg); }
.context-empty span { font-size: var(--text-sm); }
.context-session-strip { padding: 6px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-session-strip > div { min-width: 0; padding: 10px 12px; display: grid; gap: 2px; align-content: start; border-radius: var(--radius-md); background: var(--surface-subtle); }
.context-session-strip small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.context-session-strip strong { overflow: hidden; font-size: var(--text-lg); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.context-session-strip span { overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-session-strip .auth-state { grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
.context-session-strip .auth-state > span { min-width: 0; display: grid; gap: 2px; }
.context-session-strip .auth-state > svg { color: var(--muted); }
.context-session-strip .auth-state.authenticated > svg, .context-session-strip .auth-state.authenticated strong { color: var(--success); }
.context-session-strip .auth-state.unauthenticated strong { color: var(--danger); }
.context-session-strip .auth-state > i { color: var(--muted); font-size: var(--text-sm); font-style: normal; }
.context-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.context-primary { min-width: 0; display: grid; gap: 16px; }
.context-diff, .context-inventory, .context-node-browser { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.context-section-heading span { color: var(--muted); font-size: var(--text-sm); }
.diff-state { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 650; }
.diff-state.changed, .diff-state.document_changed { background: var(--warning-soft); color: var(--warning); }
.diff-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.diff-summary > span { padding: 10px 12px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); }
.diff-summary strong { color: var(--foreground); font-size: var(--text-xl); font-weight: 700; }
.diff-events { display: grid; gap: 5px; }
.diff-events span { display: flex; gap: 7px; align-items: baseline; font-size: var(--text-sm); }
.diff-events i { color: var(--success); font-style: normal; font-weight: 700; }
.diff-events .removed i { color: var(--danger); }
.diff-events .removed { color: var(--muted); text-decoration: line-through; }
.context-inventory-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.context-inventory-grid > div { min-width: 0; padding: 12px 13px; display: grid; gap: 8px; align-content: start; border-radius: var(--radius-md); background: var(--surface-subtle); }
.context-inventory-grid > div > strong { font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.context-inventory-grid > div > span { font-size: var(--text-xl); font-weight: 700; }
.context-inventory-grid ul { margin: 0; padding: 0; display: grid; gap: 6px; list-style: none; }
.context-inventory-grid li { display: flex; align-items: center; gap: 7px; font-size: var(--text-sm); }
.context-inventory-grid li b { font-weight: 600; }
.context-inventory-grid li span, .context-inventory-grid li small { overflow: hidden; color: var(--muted); white-space: nowrap; text-overflow: ellipsis; }
.context-inventory-grid li i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--border-strong); }
.context-inventory-grid li i.ready, .context-inventory-grid li i.document { background: var(--success); }
.context-inventory-grid li i.history { background: var(--primary); }
.context-inventory-grid p { margin: 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.context-node-search { position: relative; width: 240px; }
.context-node-search > svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.context-node-search input { height: 32px; padding-left: 29px; font-size: var(--text-sm); }
.context-node-head { padding: 0 12px 6px; display: grid; grid-template-columns: minmax(0, 1.6fr) 92px 62px 64px; gap: 10px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.context-node-list { max-height: 320px; overflow-y: auto; display: grid; }
.context-node-list > button { width: 100%; padding: 8px 12px; display: grid; grid-template-columns: minmax(0, 1.6fr) 92px 62px 64px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.context-node-list > button:last-child { border-bottom: 0; }
.context-node-list > button:hover { background: var(--surface-subtle); }
.context-node-list > button.active { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.context-node-list strong, .context-node-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.context-node-list strong { font-size: var(--text-md); font-weight: 600; }
.context-node-list small { margin-top: 1px; color: var(--muted); }
.context-node-list code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-node-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted); font-size: var(--text-xs); font-style: normal; font-weight: 600; text-align: center; }
.context-node-list i.ready { background: var(--success-soft); color: var(--success); }
.context-inspector { min-width: 0; display: grid; gap: 16px; position: sticky; top: 76px; }
.context-inspector > section { padding: 16px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-inspector-empty { padding: 14px 12px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.context-node-error { padding: 9px 12px; display: flex; gap: 8px; align-items: flex-start; border-radius: var(--radius-md); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); }
.node-identity { display: grid; gap: 3px; }
.node-identity code { color: var(--muted); font-size: var(--text-sm); }
.node-identity strong { font-size: var(--text-lg); font-weight: 650; overflow-wrap: anywhere; }
.node-identity span { color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }
.node-properties { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.node-properties dt { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.node-properties dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.node-actions { display: flex; gap: 8px; }
.node-value-editor { display: flex; gap: 8px; align-items: flex-end; }
.node-value-editor .ui-field { flex: 1; }
.auth-evidence ul { margin: 0; padding-left: 18px; display: grid; gap: 6px; font-size: var(--text-sm); line-height: 1.5; }
.signal-names { display: grid; gap: 4px; font-size: var(--text-sm); }
.signal-names strong { font-weight: 650; }
.signal-names span { color: var(--muted); overflow-wrap: anywhere; }
.context-utility-panel { max-width: 760px; display: grid; gap: 13px; align-content: start; }
.context-utility-panel > p { margin: 0; color: var(--muted); font-size: var(--text-sm); }
.eval-mode { justify-self: start; }
.eval-warning { padding: 10px 13px; display: flex; gap: 9px; align-items: flex-start; border-radius: var(--radius-md); background: var(--primary-soft); color: var(--primary-text); font-size: var(--text-sm); line-height: 1.5; }
.eval-warning svg { flex: 0 0 auto; margin-top: 1px; }
.code-editor { font-family: var(--font-mono); font-size: var(--text-sm); }
.eval-result-meta { display: flex; flex-wrap: wrap; gap: 7px; }
.eval-result-meta span { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
.invoke-result { max-height: 320px; }
.context-json { display: grid; gap: 10px; }
.context-json pre { max-height: 560px; }
.panel-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.panel-title > span { font-size: var(--text-lg); font-weight: 650; }
/* ---------- 引擎连接 ---------- */
.managed-policy-banner { padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start; border-radius: var(--radius-lg); background: var(--warning-soft); box-shadow: var(--shadow-sm); }
.managed-policy-banner > svg { flex: 0 0 auto; margin-top: 2px; color: var(--warning); }
.managed-policy-banner strong, .managed-policy-banner small { display: block; }
.managed-policy-banner strong { font-size: var(--text-md); font-weight: 650; }
.managed-policy-banner small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-sm); }
.managed-policy-banner i { display: block; margin-top: 3px; color: var(--warning); font-size: var(--text-sm); font-style: normal; }
.bridge-identity-strip { padding: 13px 18px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.bridge-identity-strip > div { min-width: 0; }
.bridge-identity-strip span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.bridge-identity-strip code, .bridge-identity-strip strong { display: block; margin-top: 4px; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.engine-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; }
.settings-form { min-width: 0; display: grid; gap: 16px; }
.pairing-workspace { padding: 18px; display: grid; gap: 15px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.pairing-workspace.pending { box-shadow: inset 3px 0 0 var(--warning), var(--shadow-sm); }
.pairing-workspace.paired { box-shadow: inset 3px 0 0 var(--success), var(--shadow-sm); }
.pairing-workspace.error { box-shadow: inset 3px 0 0 var(--danger), var(--shadow-sm); }
.pairing-workspace__heading { display: flex; gap: 13px; align-items: flex-start; }
.pairing-icon { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; border-radius: var(--radius-md); background: var(--primary-soft); color: var(--primary-text); }
.pairing-workspace__heading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* 未配对 idle 态:居中 hero,配对是该页此时的主任务 */
.pairing-workspace.idle { padding: 30px 22px 22px; justify-items: center; text-align: center; }
.pairing-workspace.idle .pairing-workspace__heading { flex-direction: column; align-items: center; gap: 12px; }
.pairing-workspace.idle .pairing-icon { width: 52px; height: 52px; border-radius: var(--radius-lg); }
.pairing-workspace.idle .pairing-icon svg { width: 24px; height: 24px; }
.pairing-workspace.idle .editor-actions { justify-content: center; }
.pairing-code { padding: 16px; display: grid; gap: 4px; justify-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); text-align: center; }
.pairing-code span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.pairing-code strong { font-family: var(--font-mono); font-size: 30px; font-weight: 700; letter-spacing: .12em; }
.pairing-code small { color: var(--muted); font-size: var(--text-sm); }
.paired-engine-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.paired-engine-meta > div { min-width: 0; }
.paired-engine-meta span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.paired-engine-meta code { display: block; margin-top: 3px; overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.advanced-connection { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.advanced-connection > summary { padding: 15px 18px; font-size: var(--text-md); font-weight: 650; cursor: pointer; list-style-position: inside; }
.advanced-connection__body { padding: 2px 18px 16px; display: grid; gap: 13px; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; cursor: pointer; }
.toggle-row > span { min-width: 0; }
.toggle-row strong { font-size: var(--text-md); font-weight: 600; }
.toggle-row small { display: block; margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.panel-policy-settings { padding: 18px; display: grid; gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.panel-policy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grant-editor { padding: 18px; display: grid; gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.grant-editor > p { margin: -6px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.tab-picker { display: grid; gap: 10px; }
.tab-picker-group { padding: 6px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.tab-picker-group label { padding: 7px 9px; display: flex; align-items: flex-start; gap: 10px; border-radius: var(--radius-sm); cursor: pointer; }
.tab-picker-group label:hover { background: var(--surface); }
.tab-picker-group label > input { margin-top: 2px; }
.tab-picker-group label > span { min-width: 0; }
.tab-picker-group label strong, .tab-picker-group label small { display: block; }
.tab-picker-group label strong { font-size: var(--text-md); font-weight: 600; }
.tab-picker-group label small { margin-top: 1px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.tab-picker-group .frame-target { margin-left: 25px; }
.grant-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grant-risk-toggle { padding: 10px 13px; border-radius: var(--radius-md); background: var(--warning-soft); }
.grant-scope-list { display: flex; flex-wrap: wrap; gap: 6px; }
.grant-scope-list span { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
.grant-status { padding: 11px 14px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--success-soft); }
.grant-status strong { color: var(--success); font-size: var(--text-md); font-weight: 650; }
.grant-status span { color: var(--muted-strong); font-size: var(--text-sm); }
.protocol-panel { padding: 18px; display: grid; gap: 4px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); position: sticky; top: 76px; }
.protocol-panel h2 { margin-bottom: 10px; }
.protocol-panel > div { padding: 9px 0; display: grid; gap: 3px; border-bottom: 1px solid var(--border); }
.protocol-panel > div:last-child { border-bottom: 0; }
.protocol-panel code { color: var(--primary-text); font-size: var(--text-sm); font-weight: 600; }
.protocol-panel span { color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* ---------- 窄屏适配 ---------- */
@media (max-width: 1080px) {
.task-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.network-layout, .observation-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); }
.network-inspector, .context-inspector, .cookie-editor-pane, .protocol-panel { position: static; }
.proxy-tools { grid-template-columns: minmax(0, 1fr); }
.bridge-identity-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.proxy-list-card .ant-card-body {
padding: 24px;
@media (max-width: 720px) {
.app-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.sidebar-brand { height: 56px; }
.sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; padding: 10px; }
.sidebar nav button { width: auto; grid-template-columns: 18px 1fr; }
.sidebar nav button > svg:last-child { display: none; }
.sidebar-theme { margin-top: 0; grid-auto-flow: column; align-items: center; justify-content: space-between; }
.sidebar-theme select { width: 150px; }
.sidebar-status { min-height: 54px; }
.topbar { padding: 0 16px; }
.content-area { padding: 16px; }
.page-heading { flex-direction: column; align-items: flex-start; }
.task-command-bar, .agent-runtime-summary { flex-direction: column; display: flex; align-items: stretch; }
.task-status-grid, .context-session-strip, .diff-summary, .context-inventory-grid, .grant-options, .panel-policy-grid, .form-grid, .paired-engine-meta { grid-template-columns: minmax(0, 1fr); }
.agent-action-row { grid-template-columns: 12px 76px minmax(0, 1fr) 76px; }
.agent-action-row span:nth-child(4), .agent-action-row span:last-child { display: none; }
.activity-table__head, .activity-table__row { grid-template-columns: 120px minmax(0, 1fr) 80px; }
.activity-table__head span:nth-child(2), .activity-table__head span:nth-child(4), .activity-table__head span:last-child,
.activity-table__row > span:nth-child(2), .activity-table__row > span:nth-child(4), .activity-table__row > span:last-child { display: none; }
.network-table-head, .network-row { grid-template-columns: 56px 50px minmax(0, 1fr) 66px; }
.network-table-head span:nth-child(4), .network-row > span:nth-child(4) { display: none; }
.observation-table-head, .observation-row { grid-template-columns: 76px minmax(0, 1fr) 80px; }
.observation-table-head span:nth-child(2), .observation-table-head span:nth-child(4),
.observation-row > span:nth-child(2), .observation-row > span:nth-child(4) { display: none; }
.cookie-columns { grid-template-columns: 24px minmax(0, 1fr) minmax(0, 1fr) 34px; }
.cookie-columns > span:nth-child(4), .cookie-columns > span:nth-child(5) { display: none; }
.cookie-toolbar select { min-width: 0; flex: 1; }
.context-node-head { display: none; }
.context-node-list > button { grid-template-columns: minmax(0, 1fr) 64px; }
.context-node-list code, .context-node-list i { display: none; }
.rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(0, 1fr) 34px; }
.rule-table .table-row > span, .rule-table .table-head > span { display: none; }
.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: minmax(0, 1fr) 34px; }
.proxy-rule-table .table-row > span, .proxy-rule-table .table-row > svg, .proxy-rule-table .table-head > span { display: none; }
}
.proxy-list-card .ant-list-item {
padding: 16px 24px;
transition: all 0.3s;
}
.proxy-list-card .ant-list-item:hover {
background-color: rgba(242, 139, 68, 0.05);
}
.add-proxy-card {
margin-bottom: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.add-proxy-card .ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 10px 24px;
border-top: 1px solid #f0f0f0;
}
.add-proxy-card .ant-modal-footer button {
margin-left: 8px;
}
.required-label::before {
content: '* ';
color: #ff4d4f;
}
.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before {
display: none !important;
}
.ant-space {
width: 100%;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yaklang 代理管理设置</title>
<title>Yakit Browser Agent</title>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
</html>
+3
View File
@@ -1,8 +1,11 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css'
import './style.css';
watchTheme();
const root = createRoot(document.getElementById('app')!);
root.render(<App/>);
+1 -9
View File
@@ -1,9 +1 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
* {
box-sizing: border-box;
}
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
+141
View File
@@ -0,0 +1,141 @@
import {
PAGE_REQUEST_EVENT,
PAGE_RESPONSE_EVENT,
type PageBridgeRequest,
type PageBridgeResponse,
} from '@/features/page-context/protocol';
export default defineUnlistedScript(() => {
const script = document.currentScript;
if (!script || script.getAttribute('data-yakit-page-bridge-ready') === 'true') return;
script.setAttribute('data-yakit-page-bridge-ready', 'true');
const MAX_DEPTH = 6;
const MAX_ITEMS = 100;
const MAX_STRING = 100_000;
function serialize(value: unknown): { value: unknown; type: string; preview: string; truncated: boolean } {
const seen = new WeakSet<object>();
let truncated = false;
const visit = (input: unknown, depth: number): unknown => {
if (input === null) return null;
if (typeof input === 'string') {
if (input.length > MAX_STRING) truncated = true;
return input.slice(0, MAX_STRING);
}
if (typeof input === 'number' || typeof input === 'boolean') return input;
if (typeof input === 'undefined') return { $type: 'undefined' };
if (typeof input === 'bigint') return { $type: 'bigint', value: input.toString() };
if (typeof input === 'symbol') return { $type: 'symbol', value: String(input) };
if (typeof input === 'function') {
const source = Function.prototype.toString.call(input);
if (source.length > 2_000) truncated = true;
return { $type: 'function', name: input.name || '', source: source.slice(0, 2_000) };
}
if (depth >= MAX_DEPTH) {
truncated = true;
return { $type: 'max-depth', constructor: (input as object).constructor?.name || 'Object' };
}
if (seen.has(input as object)) return { $type: 'circular' };
seen.add(input as object);
if (input instanceof Error) {
return { $type: 'error', name: input.name, message: input.message, stack: input.stack?.slice(0, 10_000) };
}
if (input instanceof Date) return { $type: 'date', value: input.toISOString() };
if (input instanceof RegExp) return { $type: 'regexp', value: String(input) };
if (input instanceof Node) {
const element = input instanceof Element ? input : input.parentElement;
const html = element?.outerHTML || input.textContent || '';
if (html.length > 10_000) truncated = true;
return {
$type: 'node',
name: input.nodeName,
html: html.slice(0, 10_000),
};
}
if (Array.isArray(input)) {
if (input.length > MAX_ITEMS) truncated = true;
return input.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1));
}
const output: Record<string, unknown> = {};
const keys = Reflect.ownKeys(input as object).slice(0, MAX_ITEMS);
if (Reflect.ownKeys(input as object).length > MAX_ITEMS) truncated = true;
for (const key of keys) {
const name = typeof key === 'symbol' ? `[${String(key)}]` : key;
try {
output[name] = visit(Reflect.get(input 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,
};
}
script.addEventListener(PAGE_REQUEST_EVENT, (rawEvent) => {
if (!(rawEvent instanceof CustomEvent) || typeof rawEvent.detail !== 'string') return;
void (async () => {
let request: PageBridgeRequest;
try {
request = JSON.parse(rawEvent.detail) as PageBridgeRequest;
} catch {
return;
}
const startedAt = performance.now();
let response: PageBridgeResponse;
try {
let rawResult: unknown;
if (request.operation === 'eval') {
const source = request.mode === 'expression'
? `(${request.code}\n)`
: `(async () => {\n${request.code}\n})()`;
rawResult = (0, eval)(source);
} else {
const segments = request.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(`${request.path} is not a function`);
rawResult = Reflect.apply(target, owner, request.args);
}
const result = serialize(await rawResult);
response = {
id: request.id,
ok: true,
result: { ...result, durationMs: Math.round((performance.now() - startedAt) * 100) / 100 },
};
} catch (error) {
response = {
id: request.id,
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,
},
};
}
script.dispatchEvent(new CustomEvent(PAGE_RESPONSE_EVENT, { detail: JSON.stringify(response) }));
})();
});
});
+453
View File
@@ -0,0 +1,453 @@
type ObservationKind = 'fetch' | 'xhr' | 'form' | 'websocket' | 'webcrypto' | 'cryptojs';
interface ObserverOptions {
captureValues: boolean;
maxEntries: number;
maxValueBytes: number;
expiresAt?: number;
}
interface ObserverRecord {
id: string;
sequence: number;
timestamp: number;
kind: ObservationKind;
operation: string;
url?: string;
method?: string;
algorithm?: string;
direction?: 'send' | 'receive';
socketId?: string;
byteLength?: number;
resultByteLength?: number;
dataType?: string;
stack?: string;
scriptUrl?: string;
sensitiveCaptured: boolean;
inputPreview?: string;
outputPreview?: string;
error?: string;
}
interface ObserverSnapshot {
version: 2;
active: boolean;
startedAt?: number;
count: number;
droppedCount: number;
options?: ObserverOptions;
records: ObserverRecord[];
}
interface ObserverController {
version: 2;
command(command: 'start' | 'status' | 'list' | 'clear' | 'stop', input?: Partial<ObserverOptions> & { limit?: number }): ObserverSnapshot;
}
interface LegacyObserverController {
version?: unknown;
command?: (command: 'stop', input?: Record<string, never>) => unknown;
}
type ObserverRecordInput = Omit<ObserverRecord, 'id' | 'sequence' | 'timestamp' | 'sensitiveCaptured'>;
export default defineUnlistedScript(() => {
const REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V2__';
const LEGACY_REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V1__';
const registry = window as unknown as Record<string, unknown>;
const existing = registry[REGISTRY_KEY] as ObserverController | undefined;
if (existing?.version === 2) return;
const legacy = registry[LEGACY_REGISTRY_KEY] as LegacyObserverController | undefined;
try {
if (legacy?.version === 1 && typeof legacy.command === 'function') legacy.command('stop');
} catch {
// A stale observer must not block the current controller from installing.
}
const encoder = new TextEncoder();
const restorers: Array<() => void> = [];
let cryptoJsTimer: number | undefined;
let expiryTimer: number | undefined;
let active = false;
let startedAt: number | undefined;
let observationSession = 0;
let sequence = 0;
let socketSequence = 0;
let droppedCount = 0;
let records: ObserverRecord[] = [];
let options: ObserverOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
function dataType(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value !== 'object') return typeof value;
return Object.prototype.toString.call(value).slice(8, -1);
}
function byteLength(value: unknown): number | undefined {
try {
if (typeof value === 'string') return encoder.encode(value).byteLength;
if (value instanceof Blob) return value.size;
if (value instanceof ArrayBuffer) return value.byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
if (value instanceof URLSearchParams) return encoder.encode(value.toString()).byteLength;
if (typeof FormData !== 'undefined' && value instanceof FormData) {
let total = 0;
for (const [key, item] of value.entries()) total += encoder.encode(key).byteLength + (typeof item === 'string' ? encoder.encode(item).byteLength : item.size);
return total;
}
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
return Math.max(0, (value as { sigBytes: number }).sigBytes);
}
if (value !== undefined) return encoder.encode(JSON.stringify(value)).byteLength;
} catch {
return undefined;
}
return undefined;
}
function preview(value: unknown): string | undefined {
if (!options.captureValues || value === undefined) return undefined;
try {
let output: string;
if (typeof value === 'string') output = value;
else if (value instanceof URLSearchParams) output = value.toString();
else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value) || value instanceof Blob) output = `[binary ${byteLength(value) || 0} bytes]`;
else if (typeof FormData !== 'undefined' && value instanceof FormData) {
output = JSON.stringify([...value.entries()].map(([key, item]) => [key, typeof item === 'string' ? item : `[file ${item.size} bytes]`]));
} else if (value && typeof value === 'object' && typeof (value as { toString?: unknown }).toString === 'function') {
const cryptoText = (value as { toString(): string }).toString();
output = cryptoText === '[object Object]' ? JSON.stringify(value) : cryptoText;
} else output = String(value);
const bytes = encoder.encode(output);
if (bytes.byteLength <= options.maxValueBytes) return output;
return new TextDecoder().decode(bytes.slice(0, options.maxValueBytes));
} catch {
return `[${dataType(value)}]`;
}
}
function stackInfo(): { stack?: string; scriptUrl?: string } {
try {
const stack = new Error().stack?.split('\n').slice(2, 10).join('\n').slice(0, 4_096);
const scriptUrl = stack?.match(/https?:\/\/[^\s)]+/)?.[0]?.slice(0, 2_048);
return { stack, scriptUrl };
} catch {
return {};
}
}
function record(input: ObserverRecordInput): ObserverRecord | undefined {
if (!active) return undefined;
const nextSequence = sequence + 1;
const item: ObserverRecord = {
id: `observation-${startedAt || Date.now()}-${observationSession}-${nextSequence}`,
sequence: nextSequence,
timestamp: Date.now(),
sensitiveCaptured: options.captureValues,
...input,
};
sequence = nextSequence;
records.push(item);
while (records.length > options.maxEntries) {
records.shift();
droppedCount += 1;
}
return item;
}
function observe(factory: () => ObserverRecordInput): ObserverRecord | undefined {
if (!active) return undefined;
try {
return record(factory());
} catch {
droppedCount += 1;
return undefined;
}
}
function bestEffort(operation: () => void): void {
try {
operation();
} catch {
// Observation is diagnostic and must never change the target page's behavior.
}
}
function errorMessage(error: unknown): string {
try {
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
} catch {
return 'Unknown error';
}
}
function algorithmSummary(value: unknown): string | undefined {
if (typeof value === 'string') return value.slice(0, 160);
if (!value || typeof value !== 'object') return undefined;
const algorithm = value as Record<string, unknown>;
const name = typeof algorithm.name === 'string' ? algorithm.name : 'unknown';
const parts = [name];
if (typeof algorithm.namedCurve === 'string') parts.push(`curve=${algorithm.namedCurve}`);
if (typeof algorithm.length === 'number') parts.push(`length=${algorithm.length}`);
if (typeof algorithm.tagLength === 'number') parts.push(`tag=${algorithm.tagLength}`);
const hash = algorithm.hash;
if (typeof hash === 'string') parts.push(`hash=${hash}`);
else if (hash && typeof hash === 'object' && typeof (hash as { name?: unknown }).name === 'string') parts.push(`hash=${(hash as { name: string }).name}`);
if (algorithm.iv !== undefined) parts.push(`ivBytes=${byteLength(algorithm.iv) || 0}`);
if (algorithm.salt !== undefined) parts.push(`saltBytes=${byteLength(algorithm.salt) || 0}`);
return parts.join(' ').slice(0, 240);
}
function patchFetch(): void {
const original = window.fetch;
if (typeof original !== 'function') return;
const wrapped: typeof window.fetch = function observedFetch(this: Window, input, init) {
observe(() => {
const request = typeof Request !== 'undefined' && input instanceof Request ? input : undefined;
const url = request?.url || String(input);
const method = init?.method || request?.method || 'GET';
const body = init?.body;
return { kind: 'fetch', operation: 'fetch', url: url.slice(0, 8_192), method: method.toUpperCase().slice(0, 32), byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
});
return Reflect.apply(original, this, [input, init]);
};
window.fetch = wrapped;
restorers.push(() => { if (window.fetch === wrapped) window.fetch = original; });
}
function patchXhr(): void {
if (typeof XMLHttpRequest === 'undefined') return;
const states = new WeakMap<XMLHttpRequest, { method: string; url: string; headerCount: number }>();
const prototype = XMLHttpRequest.prototype;
const originalOpen = prototype.open;
const originalSend = prototype.send;
const originalSetHeader = prototype.setRequestHeader;
const wrappedOpen = function observedOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
bestEffort(() => {
states.set(this, { method: String(method).toUpperCase().slice(0, 32), url: String(url).slice(0, 8_192), headerCount: 0 });
});
return Reflect.apply(originalOpen, this, [method, url, ...rest] as Parameters<XMLHttpRequest['open']>);
} as typeof prototype.open;
const wrappedSetHeader = function observedSetRequestHeader(this: XMLHttpRequest, name: string, value: string) {
bestEffort(() => {
const state = states.get(this);
if (state) state.headerCount += 1;
});
return Reflect.apply(originalSetHeader, this, [name, value]);
};
const wrappedSend = function observedSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
observe(() => {
const state = states.get(this);
return { kind: 'xhr', operation: 'send', url: state?.url, method: state?.method, byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
});
return Reflect.apply(originalSend, this, [body]);
};
const restore = () => {
if (prototype.open === wrappedOpen) prototype.open = originalOpen;
if (prototype.setRequestHeader === wrappedSetHeader) prototype.setRequestHeader = originalSetHeader;
if (prototype.send === wrappedSend) prototype.send = originalSend;
};
try {
prototype.open = wrappedOpen;
prototype.setRequestHeader = wrappedSetHeader;
prototype.send = wrappedSend;
} catch (error) {
bestEffort(restore);
throw error;
}
restorers.push(restore);
}
function patchForms(): void {
const onSubmit = (event: Event) => {
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
if (!form) return;
observe(() => {
let body: FormData | undefined;
try { body = new FormData(form); } catch { /* Some custom forms cannot be serialized. */ }
return {
kind: 'form', operation: 'submit', url: form.action.slice(0, 8_192), method: form.method.toUpperCase().slice(0, 32),
byteLength: byteLength(body), dataType: 'FormData', inputPreview: preview(body), ...stackInfo(),
};
});
};
document.addEventListener('submit', onSubmit, true);
restorers.push(() => document.removeEventListener('submit', onSubmit, true));
}
function patchWebSocket(): void {
const Original = window.WebSocket;
if (typeof Original !== 'function') return;
const Wrapped = new Proxy(Original, {
construct(target, args) {
const socket = Reflect.construct(target, args) as WebSocket;
bestEffort(() => {
const socketId = `socket-${startedAt || Date.now()}-${observationSession}-${++socketSequence}`;
const socketUrl = String(args[0] || '').slice(0, 8_192);
observe(() => ({ kind: 'websocket', operation: 'construct', url: socketUrl, socketId, ...stackInfo() }));
const originalSend = socket.send;
const wrappedSend = function observedSend(this: WebSocket, data: string | ArrayBufferLike | Blob | ArrayBufferView) {
observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'send', url: socketUrl, socketId, byteLength: byteLength(data), dataType: dataType(data), inputPreview: preview(data), ...stackInfo() }));
return Reflect.apply(originalSend, this, [data]);
};
const onOpen = () => observe(() => ({ kind: 'websocket', operation: 'open', url: socketUrl, socketId }));
const onMessage = (event: MessageEvent) => observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'receive', url: socketUrl, socketId, byteLength: byteLength(event.data), dataType: dataType(event.data), outputPreview: preview(event.data) }));
const onClose = (event: CloseEvent) => observe(() => ({ kind: 'websocket', operation: 'close', url: socketUrl, socketId, error: event.wasClean ? undefined : `code=${event.code}` }));
const onError = () => observe(() => ({ kind: 'websocket', operation: 'error', url: socketUrl, socketId, error: 'WebSocket error' }));
restorers.push(() => {
if (socket.send === wrappedSend) socket.send = originalSend;
socket.removeEventListener('open', onOpen);
socket.removeEventListener('message', onMessage);
socket.removeEventListener('close', onClose);
socket.removeEventListener('error', onError);
});
socket.send = wrappedSend;
socket.addEventListener('open', onOpen);
socket.addEventListener('message', onMessage);
socket.addEventListener('close', onClose);
socket.addEventListener('error', onError);
});
return socket;
},
});
window.WebSocket = Wrapped;
restorers.push(() => { if (window.WebSocket === Wrapped) window.WebSocket = Original; });
}
function patchWebCrypto(): void {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return;
const prototype = Object.getPrototypeOf(subtle) as Record<string, unknown>;
const operations = ['encrypt', 'decrypt', 'sign', 'verify', 'digest', 'deriveBits', 'deriveKey', 'generateKey', 'importKey', 'exportKey', 'wrapKey', 'unwrapKey'] as const;
for (const operation of operations) {
const original = prototype[operation];
if (typeof original !== 'function') continue;
const wrapped = function observedWebCrypto(this: SubtleCrypto, ...args: unknown[]) {
const item = observe(() => {
const input = args.find((value, index) => index > 0 && (typeof value === 'string' || value instanceof ArrayBuffer || ArrayBuffer.isView(value)));
return { kind: 'webcrypto', operation, algorithm: algorithmSummary(args[0]), byteLength: byteLength(input), dataType: dataType(input), inputPreview: preview(input), ...stackInfo() };
});
try {
const result = Reflect.apply(original, this, args) as Promise<unknown>;
void result.then((output) => {
if (item) {
item.resultByteLength = byteLength(output);
item.outputPreview = preview(output);
}
}, (error) => { if (item) item.error = errorMessage(error); });
return result;
} catch (error) {
if (item) item.error = errorMessage(error);
throw error;
}
};
prototype[operation] = wrapped;
restorers.push(() => { if (prototype[operation] === wrapped) prototype[operation] = original; });
}
}
const cryptoJsRestorers: Array<() => void> = [];
const cryptoJsWrappers = new WeakSet<Function>();
function patchCryptoJs(): void {
const cryptoJs = (window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS;
if (!cryptoJs) return;
const paths = [
'AES.encrypt', 'AES.decrypt', 'DES.encrypt', 'DES.decrypt', 'TripleDES.encrypt', 'TripleDES.decrypt',
'RC4.encrypt', 'RC4.decrypt', 'Rabbit.encrypt', 'Rabbit.decrypt', 'MD5', 'SHA1', 'SHA224', 'SHA256',
'SHA384', 'SHA512', 'SHA3', 'RIPEMD160', 'HmacMD5', 'HmacSHA1', 'HmacSHA224', 'HmacSHA256',
'HmacSHA384', 'HmacSHA512', 'PBKDF2', 'EvpKDF',
];
for (const path of paths) {
const segments = path.split('.');
let owner: Record<string, unknown> = cryptoJs;
for (const segment of segments.slice(0, -1)) {
const next = owner[segment];
if (!next || typeof next !== 'object') { owner = {}; break; }
owner = next as Record<string, unknown>;
}
const key = segments.at(-1)!;
const original = owner[key];
if (typeof original !== 'function' || cryptoJsWrappers.has(original)) continue;
const wrapped = function observedCryptoJs(this: unknown, ...args: unknown[]) {
const item = observe(() => ({ kind: 'cryptojs', operation: path, algorithm: path.split('.')[0], byteLength: byteLength(args[0]), dataType: dataType(args[0]), inputPreview: preview(args[0]), ...stackInfo() }));
try {
const output = Reflect.apply(original, this, args);
if (item) {
item.resultByteLength = byteLength(output);
item.outputPreview = preview(output);
}
return output;
} catch (error) {
if (item) item.error = errorMessage(error);
throw error;
}
};
owner[key] = wrapped;
cryptoJsWrappers.add(wrapped);
const restore = () => { if (owner[key] === wrapped) owner[key] = original; };
cryptoJsRestorers.push(restore);
}
}
function stop(): void {
active = false;
if (expiryTimer !== undefined) window.clearTimeout(expiryTimer);
if (cryptoJsTimer !== undefined) window.clearInterval(cryptoJsTimer);
expiryTimer = undefined;
cryptoJsTimer = undefined;
while (cryptoJsRestorers.length) {
const restore = cryptoJsRestorers.pop();
if (restore) bestEffort(restore);
}
while (restorers.length) {
const restore = restorers.pop();
if (restore) bestEffort(restore);
}
}
function snapshot(limit = options.maxEntries): ObserverSnapshot {
return {
version: 2,
active,
startedAt,
count: records.length,
droppedCount,
options: startedAt ? { ...options } : undefined,
records: records.slice(-Math.max(0, Math.min(limit, options.maxEntries))),
};
}
const controller: ObserverController = {
version: 2,
command(command, input = {}) {
if (command === 'start') {
stop();
options = {
captureValues: input.captureValues === true,
maxEntries: Math.max(10, Math.min(Number(input.maxEntries) || 100, 200)),
maxValueBytes: Math.max(256, Math.min(Number(input.maxValueBytes) || 2_048, 8_192)),
expiresAt: typeof input.expiresAt === 'number' ? input.expiresAt : undefined,
};
records = [];
droppedCount = 0;
sequence = 0;
socketSequence = 0;
observationSession += 1;
startedAt = Date.now();
active = true;
for (const patch of [patchFetch, patchXhr, patchForms, patchWebSocket, patchWebCrypto, patchCryptoJs]) {
bestEffort(patch);
}
cryptoJsTimer = window.setInterval(() => bestEffort(patchCryptoJs), 1_000);
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
} else if (command === 'clear') {
records = [];
droppedCount = 0;
} else if (command === 'stop') stop();
return snapshot(typeof input.limit === 'number' ? input.limit : options.maxEntries);
},
};
Object.defineProperty(registry, REGISTRY_KEY, { value: controller, configurable: true, enumerable: false, writable: false });
});
+72 -83
View File
@@ -1,90 +1,79 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 0;
text-align: center;
}
.popup-shell { width: 390px; display: flex; flex-direction: column; background: var(--surface); }
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #54bc4ae0);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
/* Header */
.popup-header { padding: 12px 16px 10px; border-bottom: 1px solid var(--border); color: var(--foreground); }
.popup-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.popup-brand-actions { display: flex; align-items: center; gap: 4px; }
.popup-brand-actions .ui-button { color: var(--muted-strong); }
.popup-brand-actions .ui-button:hover { background: var(--surface-subtle); color: var(--foreground); }
.popup-engine-pill { height: 26px; padding: 0 10px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; cursor: pointer; transition: background-color .15s ease, border-color .15s ease; }
.popup-engine-pill:hover { background: var(--border); }
.popup-engine-pill:disabled { opacity: .55; cursor: not-allowed; }
.popup-engine-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.popup-engine-pill.connected { border-color: color-mix(in srgb, var(--success) 40%, var(--surface)); background: var(--success-soft); color: var(--success); }
.popup-engine-pill.connected i { background: var(--success); }
.popup-engine-pill.connecting i, .popup-engine-pill.negotiating i { background: var(--warning); animation: pulse 1.3s infinite; }
.popup-engine-pill.error i { background: var(--danger); }
.popup-tab-line { min-width: 0; margin: 8px -6px 0; padding: 3px 6px; display: flex; align-items: center; gap: 7px; border-radius: 4px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.popup-tab-line:hover { background: var(--surface-subtle); }
.popup-tab-line > span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.popup-favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; }
.popup-favicon img { width: 16px; height: 16px; object-fit: contain; }
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 人工接管 —— 内嵌警告卡 */
.popup-handoff { margin: 10px 12px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 14px 12px 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-md); background: var(--warning-soft); }
.popup-handoff > svg { margin-top: 1px; color: var(--warning); }
.popup-handoff__copy { min-width: 0; }
.popup-handoff__copy strong, .popup-handoff__copy span, .popup-handoff__copy small { display: block; }
.popup-handoff__copy strong { font-size: var(--text-md); font-weight: 600; line-height: 18px; }
.popup-handoff__copy span { margin-top: 3px; font-size: var(--text-sm); line-height: 16px; overflow-wrap: anywhere; }
.popup-handoff__copy small { margin-top: 4px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; white-space: nowrap; text-overflow: ellipsis; }
.popup-handoff__actions { display: flex; gap: 4px; align-items: center; }
.popup-handoff__actions .ui-button { white-space: nowrap; }
.popup-handoff__actions .ui-button--icon { width: 30px; height: 30px; }
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
/* 共享会话 */
.popup-share { padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); transition: background-color .16s ease; }
.popup-share.is-active { background: var(--success-soft); }
.popup-share-copy { min-width: 0; display: flex; align-items: flex-start; gap: 10px; }
.popup-share-copy > svg { width: 18px; height: 18px; margin-top: 1px; flex: 0 0 auto; color: var(--muted-strong); }
.popup-share.is-active .popup-share-copy > svg { color: var(--success); }
.popup-share-copy strong, .popup-share-copy span { display: block; }
.popup-share-copy strong { font-size: var(--text-md); font-weight: 600; line-height: 18px; }
.popup-share-copy span { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.popup-share.is-active .popup-share-copy span { color: var(--success); }
.card {
padding: 2em;
}
/* 代理快切 */
.popup-proxy { padding: 10px 12px 12px; border-bottom: 1px solid var(--border); }
.popup-section-label { min-height: 22px; margin-bottom: 7px; padding: 0 4px; display: flex; align-items: center; gap: 7px; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; line-height: 16px; }
.popup-section-label .ui-badge { margin-left: auto; }
.popup-proxy-list { max-height: 172px; overflow-y: auto; display: grid; gap: 3px; scrollbar-width: thin; }
.popup-proxy-list > button { width: 100%; min-height: 40px; padding: 4px 10px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .13s ease; }
.popup-proxy-list > button:hover { background: var(--surface-subtle); }
.popup-proxy-list > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
.popup-proxy-list > button.is-active { background: var(--primary-soft); }
.popup-proxy-list > button.is-active strong { color: var(--primary-text); }
.popup-radio { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
.popup-proxy-list > button.is-active .popup-radio { border-color: var(--primary); background-color: var(--primary); }
.popup-proxy-list > button > span { min-width: 0; }
.popup-proxy-list strong, .popup-proxy-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.popup-proxy-list strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.popup-proxy-list small { margin-top: 1px; color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.read-the-docs {
color: #888;
}
/* 工具网格 */
.popup-tools { padding: 10px 12px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; border-bottom: 1px solid var(--border); }
.popup-tools button { padding: 9px 6px 8px; display: grid; justify-items: center; gap: 5px; border: 0; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: color .13s ease, background-color .13s ease; }
.popup-tools button:hover { background: var(--border); color: var(--foreground); }
.popup-tools button:hover > svg { color: var(--primary); }
.popup-tools button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
.popup-tools button > svg { color: var(--muted-strong); }
.popup-container {
min-width: 190px;
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* Footer CTA */
.popup-footer { margin-top: auto; padding: 10px 16px 12px; }
.popup-capture { width: 100%; height: 38px; font-size: var(--text-lg); }
.popup-notice { display: block; margin-top: 6px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; text-align: center; white-space: nowrap; text-overflow: ellipsis; }
.popup-content {
flex: 1;
padding: 4px 8px;
background-color: #fff;
overflow: auto;
}
/* Ensure the proxy menu takes full width */
.popup-content .proxy-switch-container {
width: 100%;
}
.popup-content .proxy-switch-container .ant-menu {
width: 100%;
border-radius: 0;
}
/* Customize scrollbar */
.popup-content::-webkit-scrollbar {
width: 4px;
}
.popup-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.popup-content::-webkit-scrollbar-thumb {
background: var(--yakit-primary);
border-radius: 2px;
}
.popup-content::-webkit-scrollbar-thumb:hover {
background: var(--yakit-primary-hover);
}
.popup-loading { width: 390px; height: 230px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); background: var(--background); font-size: var(--text-md); }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .35; } }
+198 -11
View File
@@ -1,14 +1,201 @@
import React from 'react';
import {ProxySwitch} from '@/components/ProxySwitch';
import '@/styles/global.css'
import { useCallback, useEffect, useState } from 'react';
import {
AlertTriangle, Braces, Check, Cookie, ExternalLink, Network, Radio, RefreshCw,
ShieldCheck, UserRoundCog, X,
} from 'lucide-react';
import { browser } from 'wxt/browser';
import { ProductBrand } from '@/components/brand/Brand';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipProvider } from '@/components/ui/tooltip';
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import { isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, BridgeStatus, ExtensionState, ProxyProfile } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
import './App.css';
export default function App() {
return (
<div className="popup-container">
<main className="popup-content">
<ProxySwitch/>
</main>
</div>
);
const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
fixed_servers: '固定代理',
pac_script: 'PAC Script',
direct: '直连',
system: '系统代理',
};
function proxyDetail(profile: ProxyProfile): string {
return profile.kind === 'fixed_servers'
? `${profile.scheme}://${profile.host}:${profile.port}`
: PROXY_KIND_LABELS[profile.kind];
}
function App() {
const [state, setState] = useState<ExtensionState>();
const [tab, setTab] = useState<ActiveTabInfo>();
const [bridge, setBridge] = useState<BridgeStatus>({ state: 'disconnected', message: '未连接引擎' });
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const load = useCallback(async () => {
const [nextState, nextTab, nextBridge] = await Promise.all([
request('state.get'),
request('tab.active').catch(() => undefined),
request('bridge.status'),
]);
setState(nextState);
setTab(nextTab);
setBridge(nextBridge);
}, []);
useEffect(() => {
void load();
const listener = (message: { action?: string; payload?: BridgeStatus }) => {
if (message.action === 'bridge.status.changed' && message.payload) setBridge(message.payload);
};
browser.runtime.onMessage.addListener(listener);
const onStorageChange = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) void request('state.get').then(setState).catch(() => undefined);
};
browser.storage.onChanged.addListener(onStorageChange);
return () => {
browser.runtime.onMessage.removeListener(listener);
browser.storage.onChanged.removeListener(onStorageChange);
};
}, [load]);
const grantActive = Boolean(state?.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id));
const handoff = waitingHandoff(state?.handoff);
const run = async (task: () => Promise<void>) => {
setBusy(true);
setNotice('');
try {
await task();
} catch (error) {
setNotice(errorMessage(error));
} finally {
setBusy(false);
}
};
const openTool = (tool: string) => {
const target = tab ? `?tabId=${tab.id}` : '';
return browser.tabs.create({ url: browser.runtime.getURL(`/options.html${target}#${tool}`) });
};
const toggleEngine = () => run(async () => {
if (!state!.bridge.pairedEngine) {
await request('bridge.pair');
await openTool('engine');
return;
}
if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect');
setBridge(await request('bridge.status'));
});
const capture = () => run(async () => {
const context = await request('context.capture', {
includeDom: true,
includeStorage: true,
includeCookies: true,
tabId: tab?.id,
});
await navigator.clipboard.writeText(JSON.stringify(context, null, 2));
setNotice('页面上下文已复制');
});
if (!state) {
return <div className="popup-loading"><RefreshCw size={18} className="spin" />正在读取浏览器状态</div>;
}
const engineBusy = bridge.state === 'connecting' || bridge.state === 'negotiating';
return (
<TooltipProvider delayDuration={350}>
<main className="popup-shell">
<header className="popup-header">
<div className="popup-brand-row">
<ProductBrand compact />
<div className="popup-brand-actions">
<Tooltip label={bridge.state === 'connected' ? '断开引擎连接' : state.bridge.pairedEngine ? '连接引擎' : '配对本机 Yakit'}>
<button className={`popup-engine-pill ${bridge.state}`} disabled={busy} onClick={() => void toggleEngine()}>
<i />{bridge.state === 'connected' ? '引擎在线' : engineBusy ? '连接中' : state.bridge.pairedEngine ? '引擎离线' : '配对'}
</button>
</Tooltip>
<Tooltip label="打开完整工作台">
<Button size="icon" variant="ghost" aria-label="打开完整工作台" onClick={() => void openTool('overview')}>
<ExternalLink size={16} />
</Button>
</Tooltip>
</div>
</div>
<div className="popup-tab-line">
<span className="popup-favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={12} />}</span>
<span title={tab?.url}>{tab?.title || '当前页面不可访问'}</span>
</div>
</header>
{handoff && <section className="popup-handoff" aria-live="assertive">
<AlertTriangle size={18} />
<div className="popup-handoff__copy">
<strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong>
<span>{handoff.message}</span>
<small title={handoff.target.title}>{handoff.target.title}</small>
</div>
<div className="popup-handoff__actions">
<Button size="sm" variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} />完成</Button>
<Button size="icon" variant="ghost" disabled={busy} aria-label="取消人工接管" title="取消人工接管" onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={15} /></Button>
</div>
</section>}
<section className={`popup-share ${grantActive ? 'is-active' : ''}`}>
<div className="popup-share-copy">
<ShieldCheck size={18} />
<div>
<strong>共享当前标签页</strong>
<span>{grantActive ? `只读会话 ${new Date(state.activeGrant!.expiresAt).toLocaleTimeString()} 到期` : '创建 30 分钟只读会话'}</span>
</div>
</div>
<Switch checked={grantActive} disabled={!tab || busy} aria-label="共享当前浏览器上下文" onCheckedChange={(checked) => void run(async () => {
const updated = checked
? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 })
: await request('grant.revoke');
setState(updated);
})} />
</section>
<section className="popup-proxy">
<div className="popup-section-label"><Network size={14} /><span>当前代理</span>{state.activeProxyId === 'rules' && <Badge>规则分流</Badge>}</div>
<div className="popup-proxy-list" role="radiogroup" aria-label="代理出口">
{state.proxyProfiles.map((profile) => {
const active = state.activeProxyId === profile.id;
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="popup-radio" />
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
</button>;
})}
{state.proxyRules.length > 0 && <button role="radio" aria-checked={state.activeProxyId === 'rules'} className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}>
<i className="popup-radio" />
<span><strong>按规则分流</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条启用规则</small></span>
</button>}
</div>
</section>
{!handoff && <nav className="popup-tools" aria-label="安全测试工具">
<button onClick={() => void openTool('cookies')}><Cookie size={17} /><span>Cookie</span></button>
<button onClick={() => void openTool('user-agent')}><UserRoundCog size={17} /><span>User-Agent</span></button>
<button onClick={() => void openTool('context')}><Braces size={17} /><span>登录态</span></button>
</nav>}
<footer className="popup-footer">
<Button className="popup-capture" variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void capture()}>
{busy ? <RefreshCw className="spin" size={15} /> : <Radio size={15} />}采集并复制上下文
</Button>
{notice && <span className="popup-notice">{notice}</span>}
</footer>
</main>
</TooltipProvider>
);
}
export default App;
+2 -2
View File
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<title>Yakit Browser Agent</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
+4
View File
@@ -1,8 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css';
import './style.css';
watchTheme();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
+2 -67
View File
@@ -1,67 +1,2 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
html, body, #root { margin: 0; min-width: 390px; }
body { overflow: hidden; }
-303
View File
@@ -1,303 +0,0 @@
/* Base styles for the proxy panel */
.yak-proxy-root * {
all: initial;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: normal;
margin: 0;
padding: 0;
border: none;
outline: none;
}
.floating-panel {
position: fixed;
top: 30%;
right: 0;
transform: translateY(-30%);
background: white;
z-index: 2147483647;
width: 50px;
height: 40px;
overflow: hidden;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s ease;
box-sizing: border-box;
}
/* Non-expanded state */
.floating-panel:not(.expanded):not(.dragging) {
border-radius: 50px 0 0 50px;
box-shadow: -4px 0 20px rgba(0,0,0,0.15);
border: 1px solid #eee;
border-right: none;
}
/* Dragging state */
.floating-panel.dragging {
cursor: grabbing;
user-select: none;
opacity: 0.95;
transition: none;
}
/* Hover state */
.floating-panel:not(.expanded):hover {
width: 120px;
background: #fff7e6;
border-color: #ffd591;
}
/* Expanded state */
.floating-panel.expanded {
width: 180px;
height: auto;
max-height: 400px;
border-radius: 8px 0 0 8px;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
border: 1px solid #eee;
border-right: none;
}
/* Panel header */
.panel-header {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
padding: 0 8px;
cursor: pointer;
user-select: none;
}
/* Header in expanded state */
.floating-panel.expanded .panel-header {
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.header-content {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
/* Yak icon */
.yak-icon {
width: 36px;
height: 36px;
min-width: 36px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.floating-panel.expanded .yak-icon {
width: 24px;
height: 24px;
min-width: 24px;
}
/* Active proxy info */
.active-proxy-info {
display: flex;
align-items: center;
margin-left: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #ff6b00;
font-size: 13px;
font-weight: 500;
}
.active-proxy-info span:first-child {
margin-right: 6px;
}
.active-proxy-info span:nth-child(2) {
color: #333;
}
/* Panel content */
.panel-content {
display: none;
background: white;
overflow-y: auto;
max-height: 360px;
opacity: 0;
transition: opacity 0.2s ease;
}
.floating-panel.expanded .panel-content {
display: block;
opacity: 1;
}
/* Scrollbar styles */
.panel-content::-webkit-scrollbar {
width: 4px;
}
.panel-content::-webkit-scrollbar-track {
background: #f5f5f5;
}
.panel-content::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 4px;
}
.panel-content::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
/* Proxy item */
.proxy-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
position: relative;
}
.proxy-item:hover {
background: #fff7e6;
}
.proxy-item.active {
background: #fff7e6;
color: #ff6b00;
}
.proxy-item.active span {
color: #ff6b00;
}
.proxy-item span:first-child {
margin-right: 8px;
font-size: 16px;
}
.proxy-item span {
color: #333;
}
.proxy-status {
position: absolute;
right: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #52c41a;
box-shadow: 0 0 4px rgba(82,196,26,0.3);
}
.proxy-item.active .proxy-status {
background: #ff6b00;
box-shadow: 0 0 4px rgba(255,107,0,0.3);
}
/* Divider */
.divider {
height: 1px;
background: #f0f0f0;
margin: 4px 0;
}
/* Action buttons */
.action-button {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
color: #666;
}
.action-button:hover {
background: #fff7e6;
color: #ff6b00;
}
.action-button:hover span {
color: #ff6b00;
}
.action-button span:first-child {
margin-right: 8px;
}
.action-button span {
color: #666;
}
/* Tab container */
.tabs-container {
display: flex;
height: 100%;
min-height: 200px;
}
.tab-list {
width: 40px;
background: #f8f9fa;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 8px;
position: sticky;
top: 0;
align-self: flex-start;
height: 100%;
flex-shrink: 0;
}
.tab-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background: transparent;
border: none;
padding: 0;
}
.tab-button:hover {
background: #fff7e6;
}
.tab-button.active {
background: #fff7e6;
color: #ff6b00;
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
}
.tab-panel {
display: none;
height: 100%;
overflow: hidden;
flex-direction: column;
}
.tab-panel.active {
display: flex;
}
-356
View File
@@ -1,356 +0,0 @@
import React, {useState, useEffect, useRef} from 'react';
import {browser} from 'wxt/browser';
import type {ProxyConfig} from '@/types/proxy.ts';
// Constants - using string literal instead of getURL since it will be replaced at build time
const YAK_ICON_URL = browser.runtime.getURL("/yak.svg");
// Action types from the application
const ProxyActionType = {
SET_PROXY_CONFIG: "SET_PROXY_CONFIG",
CLEAR_PROXY_CONFIG: "CLEAR_PROXY_CONFIG",
GET_PROXY_STATUS: "GET_PROXY_STATUS",
GET_PROXY_CONFIGS: "GET_PROXY_CONFIGS",
UPDATE_PROXY_CONFIG: "UPDATE_PROXY_CONFIG",
};
// Export anonymous component directly as default export
const App: React.FC = () => {
// State
const [expanded, setExpanded] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [activeTab, setActiveTab] = useState('proxy');
const [proxyStatus, setProxyStatus] = useState({
enable: false,
proxy: '',
currentMode: 'direct'
});
const [proxyConfigs, setProxyConfigs] = useState<ProxyConfig[]>([]);
// Refs
const panelRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({y: 0, top: 0});
const timeoutRef = useRef<number | null>(null);
// Setup message listener for updates
useEffect(() => {
const messageListener = async (message: any) => {
if (message.action === "PROXY_STATUS_CHANGED" || message.action === "PROXY_CONFIGS_UPDATED") {
await fetchProxyStatus();
await fetchProxyConfigs();
}
};
browser.runtime.onMessage.addListener(messageListener);
// Initial data fetch
fetchProxyStatus();
fetchProxyConfigs();
// Position from localStorage if available
const savedPosition = localStorage.getItem("yakitProxyPanelPosition");
if (savedPosition && panelRef.current) {
const top = (parseFloat(savedPosition) / 100) * window.innerHeight;
panelRef.current.style.top = `${top}px`;
panelRef.current.style.transform = 'translateY(0)';
}
return () => {
browser.runtime.onMessage.removeListener(messageListener);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Fetch current proxy status
const fetchProxyStatus = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_STATUS,
});
if (response && response.success) {
const status = response.data;
setProxyStatus({
enable: status.enabled,
proxy: status.mode === "system" ? "system" : "",
currentMode: status.mode || "direct",
});
}
} catch (error) {
console.error("Error fetching proxy status:", error);
}
};
// Fetch proxy configurations
const fetchProxyConfigs = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_CONFIGS,
});
if (response && response.success) {
setProxyConfigs(response.data || []);
}
} catch (error) {
console.error("Error fetching proxy configs:", error);
}
};
// Send message with retry logic
const sendMessageWithRetry = async (message: any, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await browser.runtime.sendMessage(message);
} catch (error) {
console.warn(`Attempt ${i + 1} failed:`, error);
if (i === maxRetries - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
};
// Handle switching to a different proxy
const handleProxySwitch = async (config: ProxyConfig) => {
try {
await sendMessageWithRetry({
action: ProxyActionType.SET_PROXY_CONFIG,
config,
});
// Update the UI
await fetchProxyStatus();
} catch (error) {
console.error("Error switching proxy:", error);
}
};
// Open options page
const openOptionsPage = async (triggerAdd = false) => {
try {
await sendMessageWithRetry({
action: "OPEN_OPTIONS_PAGE",
triggerAdd,
});
} catch (error) {
console.error("Error opening options page:", error);
}
};
// Handle dragging functionality
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (expanded) {
setExpanded(false);
return;
}
if (e.button !== 0) return; // Only left mouse button
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
dragStartRef.current = {
y: e.clientY,
top: rect.top,
};
}
e.preventDefault();
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging) return;
const deltaY = e.clientY - dragStartRef.current.y;
const newTop = dragStartRef.current.top + deltaY;
// Limit drag range to viewport
const maxTop = window.innerHeight - (panelRef.current?.offsetHeight || 0);
const boundedTop = Math.max(0, Math.min(newTop, maxTop));
if (panelRef.current) {
panelRef.current.style.top = `${boundedTop}px`;
panelRef.current.style.transform = 'translateY(0)';
}
};
const handleMouseUp = () => {
if (!isDragging) return;
setIsDragging(false);
// Save position
if (panelRef.current) {
const top = panelRef.current.getBoundingClientRect().top;
const percentage = (top / window.innerHeight) * 100;
localStorage.setItem("yakitProxyPanelPosition", percentage.toString());
}
};
// Handle mouse enter to clear any auto-collapse timeouts
const handleMouseEnter = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Handle mouse leave to auto-collapse the panel
const handleMouseLeave = () => {
if (expanded) {
timeoutRef.current = window.setTimeout(() => {
setExpanded(false);
timeoutRef.current = null;
}, 300);
}
};
// Get active proxy name and icon
let proxyIcon = "🟢";
let proxyName = "直接连接";
if (proxyStatus.currentMode === "system") {
proxyIcon = "⚙️";
proxyName = "系统代理";
} else if (proxyStatus.currentMode === "fixed_servers") {
const activeConfig = proxyConfigs.find(c => c.enabled);
if (activeConfig) {
proxyIcon = activeConfig.proxyType === "pac_script" ? "📜" : "🌐";
proxyName = activeConfig.name || "未命名代理";
}
}
return (
<div
ref={panelRef}
className={`floating-panel ${expanded ? 'expanded' : ''} ${isDragging ? 'dragging' : ''}`}
data-active-tab={activeTab}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
<div
className="panel-header"
onMouseDown={handleMouseDown}
onClick={() => !isDragging && setExpanded(!expanded)}
>
<div className="header-content">
<img src={YAK_ICON_URL} className="yak-icon" alt="Yak"/>
<div className="active-proxy-info">
<span>{proxyIcon}</span>
<span>{proxyName}</span>
</div>
</div>
</div>
{expanded && (
<div className="panel-content">
<div className="tabs-container">
<div className="tab-list">
<button
className={`tab-button ${activeTab === 'proxy' ? 'active' : ''}`}
onClick={() => setActiveTab('proxy')}
title="代理设置"
>
🌐
</button>
<button
className={`tab-button ${activeTab === 'links' ? 'active' : ''}`}
onClick={() => setActiveTab('links')}
title="页面链接"
>
🔗
</button>
</div>
<div className="tab-content">
<div className={`tab-panel ${activeTab === 'proxy' ? 'active' : ''}`} data-panel="proxy">
<div
className={`proxy-item ${proxyStatus.currentMode === 'direct' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
})}
title="直接连接"
>
<span>🟢</span>
<span>直接连接</span>
{proxyStatus.currentMode === 'direct' && <div className="proxy-status"></div>}
</div>
<div
className={`proxy-item ${proxyStatus.currentMode === 'system' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: true
})}
title="系统代理"
>
<span>⚙️</span>
<span>系统代理</span>
{proxyStatus.currentMode === 'system' && <div className="proxy-status"></div>}
</div>
<div className="divider"></div>
{proxyConfigs.map(config => {
if (config.proxyType !== 'direct' && config.proxyType !== 'system') {
const isActive = proxyStatus.currentMode === 'fixed_servers' && config.enabled;
const proxyIcon = config.proxyType === 'pac_script' ? '📜' : '🌐';
const tooltipText = config.proxyType === 'pac_script'
? 'PAC Script'
: `${config.scheme ? `${config.scheme.toUpperCase()} ` : ''}${config.host}:${config.port}`;
return (
<div
key={config.id}
className={`proxy-item ${isActive ? 'active' : ''}`}
onClick={() => handleProxySwitch({...config, enabled: true})}
title={tooltipText}
>
<span>{proxyIcon}</span>
<span>{config.name || '未命名代理'}</span>
{isActive && <div className="proxy-status"></div>}
</div>
);
}
return null;
})}
<div className="divider"></div>
<div className="action-button" onClick={() => openOptionsPage(true)}>
<span>➕</span>
<span>添加代理</span>
</div>
<div className="action-button" onClick={() => openOptionsPage(false)}>
<span>⚙️</span>
<span>设置</span>
</div>
</div>
<div className={`tab-panel ${activeTab === 'links' ? 'active' : ''}`} data-panel="links">
{/* Links panel content will be added in the future */}
<div className="links-placeholder" style={{padding: '16px', textAlign: 'center'}}>
<p>链接面板功能将在未来版本中实现</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default App;
-39
View File
@@ -1,39 +0,0 @@
import './App.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
cssInjectionMode: 'ui',
async main(ctx) {
console.log("Proxy content script starting...");
// Define your UI with shadow root for isolation
const ui = await createShadowRootUi(ctx, {
name: 'yakit-proxy-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create a wrapper div for the React app
const app = document.createElement('div');
app.id = 'yakit-proxy-root';
app.className = 'yak-proxy-root';
container.append(app);
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root?.unmount();
},
});
// Mount the UI
ui.mount();
},
});
+120
View File
@@ -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),
}));
}
+57
View File
@@ -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 不存在或删除失败');
}
+24
View File
@@ -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');
});
});
+165
View File
@@ -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');
}
+31
View File
@@ -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;
}
+46
View File
@@ -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,
})),
};
}
+77
View File
@@ -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);
});
});
+166
View File
@@ -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;
}
+755
View File
@@ -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>
);
}
+314
View File
@@ -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}`);
}
+28
View File
@@ -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;
}
+20
View File
@@ -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);
});
});
+48
View File
@@ -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) });
}
+409
View File
@@ -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,
}));
}
+18
View File
@@ -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));
}
+81
View File
@@ -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 || [],
};
});
}
+77
View File
@@ -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);
}
+24
View File
@@ -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 };
};
+763
View File
@@ -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);
}
+217
View File
@@ -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);
});
+36
View File
@@ -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');
});
});
+92
View File
@@ -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,
};
}
+170
View File
@@ -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]: [] });
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...values: ClassValue[]): string {
return clsx(values);
}
+66
View File
@@ -0,0 +1,66 @@
import { browser, type Browser } from 'wxt/browser';
import type { ActiveTabInfo, BrowserTarget } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
type DocumentProbeResult = Browser.scripting.InjectionResult & { documentId?: string };
function probeDocument() {
return { url: location.href };
}
export function scriptingTarget(target: BrowserTarget): Browser.scripting.InjectionTarget {
if (target.documentId && !import.meta.env.FIREFOX) {
return { tabId: target.tabId, documentIds: [target.documentId] } as unknown as Browser.scripting.InjectionTarget;
}
return { tabId: target.tabId, frameIds: [target.frameId] };
}
export async function resolveDocumentTarget(input: BrowserTarget | number): Promise<BrowserTarget> {
const requested: BrowserTarget = typeof input === 'number'
? { tabId: input, frameId: 0 }
: { ...input, frameId: input.frameId ?? 0 };
let probe: DocumentProbeResult | undefined;
try {
[probe] = await browser.scripting.executeScript({
target: { tabId: requested.tabId, frameIds: [requested.frameId] },
world: 'MAIN',
func: probeDocument,
}) as DocumentProbeResult[];
} catch (error) {
throw new ExtensionError('target_unavailable', error instanceof Error ? error.message : String(error));
}
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return { tabId: requested.tabId, frameId: probe.frameId, documentId: probe.documentId || requested.documentId };
}
async function findRecentHttpTab(): Promise<Browser.tabs.Tab | undefined> {
const active = (await browser.tabs.query({ active: true, currentWindow: true }))[0];
if (active?.url && /^https?:/i.test(active.url)) return active;
const tabs = await browser.tabs.query({ currentWindow: true });
return tabs.filter((tab) => tab.url && /^https?:/i.test(tab.url))
.sort((left, right) => (right.lastAccessed || 0) - (left.lastAccessed || 0))[0];
}
export async function getTab(tabId?: number): Promise<ActiveTabInfo> {
const tab = tabId ? await browser.tabs.get(tabId) : await findRecentHttpTab();
if (!tab?.id || !tab.url) throw new Error('无法读取当前标签页');
return {
id: tab.id,
windowId: tab.windowId,
title: tab.title || '未命名页面',
url: tab.url,
favIconUrl: tab.favIconUrl,
lastAccessed: tab.lastAccessed,
};
}
export const getActiveTab = () => getTab();
export async function activateTab(tabId?: number): Promise<void> {
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id);
await browser.windows.update(tab.windowId, { focused: true });
await browser.tabs.update(tab.id, { active: true });
}
+18
View File
@@ -0,0 +1,18 @@
import { browser } from 'wxt/browser';
import type { ExtensionAction, ExtensionRequest, ExtensionResponse, RequestInput, RequestOutput } from '@/types/messages';
export async function request<A extends ExtensionAction>(
action: A,
...args: undefined extends RequestInput<A> ? [payload?: RequestInput<A>] : [payload: RequestInput<A>]
): Promise<RequestOutput<A>> {
const payload = args[0];
const response = (await browser.runtime.sendMessage({ action, payload } as ExtensionRequest)) as ExtensionResponse<RequestOutput<A>>;
if (!response?.ok) {
throw new Error(response?.error || `Extension request failed: ${action}`);
}
return response.data as RequestOutput<A>;
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+24
View File
@@ -0,0 +1,24 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
import type { BridgeConfig } from '@/types/models';
import { applyPolicyToBridge, assertGrantPolicy } from './managed';
const bridge: BridgeConfig = {
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host',
autoConnect: false, installationId: 'install-1',
};
describe('managed policy enforcement', () => {
it('forces Native Messaging without replacing the paired device identity', () => {
expect(applyPolicyToBridge(bridge, { disableWebSocket: true, nativeHost: 'managed.host', autoConnect: true }))
.toEqual({ ...bridge, transport: 'native', nativeHost: 'managed.host', autoConnect: true });
});
it('caps grants and rejects origins/program Eval', () => {
expect(assertGrantPolicy({ maxGrantMinutes: 30 }, { durationMinutes: 120, origins: ['https://a.test'], programEval: false })).toBe(30);
expect(() => assertGrantPolicy({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).toThrow('不允许');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { browser } from 'wxt/browser';
import type { BridgeConfig, EnterprisePolicy, EnterprisePolicyStatus, ExtensionState } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
interface ManagedStorageArea {
get(keys?: null): Promise<Record<string, unknown>>;
}
function stringValue(input: unknown, maxLength: number): string | undefined {
return typeof input === 'string' && input.trim() && input.length <= maxLength ? input.trim() : undefined;
}
export async function getEnterprisePolicy(): Promise<EnterprisePolicyStatus> {
const area = (browser.storage as unknown as { managed?: ManagedStorageArea }).managed;
if (!area) return { managed: false, policy: {}, warnings: [] };
let input: Record<string, unknown>;
try {
input = await area.get(null);
} catch {
return { managed: false, policy: {}, warnings: [] };
}
const warnings: string[] = [];
const policy: EnterprisePolicy = {};
if (input.bridgeTransport === 'native' || input.bridgeTransport === 'websocket') policy.bridgeTransport = input.bridgeTransport;
if (input.bridgeEndpoint !== undefined) {
const value = stringValue(input.bridgeEndpoint, 2_048);
if (value) policy.bridgeEndpoint = value; else warnings.push('bridgeEndpoint 无效');
}
if (input.nativeHost !== undefined) {
const value = stringValue(input.nativeHost, 253);
if (value) policy.nativeHost = value; else warnings.push('nativeHost 无效');
}
for (const key of ['autoConnect', 'disableWebSocket', 'floatingPanelEnabled', 'allowProgramEval'] as const) {
if (typeof input[key] === 'boolean') policy[key] = input[key];
}
if (Number.isSafeInteger(input.maxGrantMinutes) && Number(input.maxGrantMinutes) >= 5 && Number(input.maxGrantMinutes) <= 1_440) {
policy.maxGrantMinutes = Number(input.maxGrantMinutes);
} else if (input.maxGrantMinutes !== undefined) warnings.push('maxGrantMinutes 无效');
if (Array.isArray(input.grantAllowedOrigins)) {
const origins: string[] = [];
for (const item of input.grantAllowedOrigins.slice(0, 500)) {
try {
if (typeof item !== 'string') throw new Error('not a string');
const origin = new URL(item).origin;
if (origin === 'null' || !/^https?:/.test(origin)) throw new Error('not HTTP(S)');
origins.push(origin);
} catch {
warnings.push('grantAllowedOrigins 包含无效 origin');
}
}
policy.grantAllowedOrigins = [...new Set(origins)];
}
return { managed: Object.keys(input).length > 0, policy, warnings: [...new Set(warnings)] };
}
export function applyPolicyToBridge(config: BridgeConfig, policy: EnterprisePolicy): BridgeConfig {
const transport = policy.disableWebSocket ? 'native' : policy.bridgeTransport || config.transport;
return {
...config,
transport,
endpoint: policy.bridgeEndpoint || config.endpoint,
nativeHost: policy.nativeHost || config.nativeHost,
autoConnect: policy.autoConnect ?? config.autoConnect,
};
}
export function applyPolicyToState(state: ExtensionState, policy: EnterprisePolicy): ExtensionState {
return {
...state,
bridge: applyPolicyToBridge(state.bridge, policy),
floatingPanel: {
...state.floatingPanel,
enabled: policy.floatingPanelEnabled ?? state.floatingPanel.enabled,
},
};
}
export function assertGrantPolicy(
policy: EnterprisePolicy,
input: { durationMinutes: number; origins: string[]; programEval: boolean },
): number {
if (input.programEval && policy.allowProgramEval === false) {
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
}
if (policy.grantAllowedOrigins?.length) {
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin));
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`);
}
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
}
+56
View File
@@ -0,0 +1,56 @@
import { browser } from 'wxt/browser';
export type ThemePreference = 'system' | 'light' | 'dark';
export const APPEARANCE_STORAGE_KEY = 'settings.appearance.v1';
interface AppearanceSettings {
theme: ThemePreference;
}
const DEFAULT_APPEARANCE: AppearanceSettings = { theme: 'system' };
export async function getAppearance(): Promise<AppearanceSettings> {
const stored = await browser.storage.local.get(APPEARANCE_STORAGE_KEY);
const value = stored[APPEARANCE_STORAGE_KEY] as AppearanceSettings | undefined;
return value && ['system', 'light', 'dark'].includes(value.theme) ? value : DEFAULT_APPEARANCE;
}
export async function setThemePreference(theme: ThemePreference): Promise<void> {
await browser.storage.local.set({ [APPEARANCE_STORAGE_KEY]: { theme } satisfies AppearanceSettings });
}
export function resolveTheme(theme: ThemePreference): 'light' | 'dark' {
if (theme !== 'system') return theme;
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Applies the stored theme to <html data-theme> and keeps it in sync with
* both the storage key and the OS color scheme. Returns a cleanup function.
*/
export function watchTheme(root: HTMLElement = document.documentElement): () => void {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
let current: ThemePreference = 'system';
const apply = () => {
root.dataset.theme = resolveTheme(current);
};
void getAppearance().then((appearance) => {
current = appearance.theme;
apply();
});
const onStorageChange = (changes: Record<string, unknown>, area: string) => {
if (area !== 'local' || !(APPEARANCE_STORAGE_KEY in changes)) return;
const next = (changes[APPEARANCE_STORAGE_KEY] as { newValue?: AppearanceSettings })?.newValue;
current = next && ['system', 'light', 'dark'].includes(next.theme) ? next.theme : 'system';
apply();
};
const onMediaChange = () => apply();
browser.storage.onChanged.addListener(onStorageChange);
media?.addEventListener('change', onMediaChange);
apply();
return () => {
browser.storage.onChanged.removeListener(onStorageChange);
media?.removeEventListener('change', onMediaChange);
};
}
+59
View File
@@ -0,0 +1,59 @@
import { vi, describe, expect, it } from 'vitest';
const stores = vi.hoisted(() => ({
local: {} as Record<string, unknown>,
session: {} as Record<string, unknown>,
}));
function area(data: Record<string, unknown>) {
return {
async get(keys: string | string[]) {
const list = Array.isArray(keys) ? keys : [keys];
return Object.fromEntries(list.filter((key) => key in data).map((key) => [key, data[key]]));
},
async set(items: Record<string, unknown>) { Object.assign(data, structuredClone(items)); },
};
}
vi.mock('wxt/browser', () => ({
browser: {
storage: { local: area(stores.local), session: area(stores.session) },
},
}));
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY,
} from '@/protocol/storage';
import { DEFAULT_STATE, getState, setState, updateState } from './state';
describe('split state storage', () => {
it('writes durable domains to local and grant/handoff to session', async () => {
const now = Date.now();
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: {
id: 'grant-1', taskId: 'task-1', createdAt: now, expiresAt: now + 60_000,
scopes: ['browser.tabs.read'],
targets: [{ tabId: 1, frameId: 0, origin: 'https://example.test', grantedUrl: 'https://example.test/', title: 'Example' }],
},
});
expect(Object.keys(stores.local)).toEqual(expect.arrayContaining([
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
]));
expect(stores.local).not.toHaveProperty('yakit-extension-state-v5');
expect(stores.session).toHaveProperty(ACTIVE_SESSION_STORAGE_KEY);
expect((await getState()).activeGrant?.taskId).toBe('task-1');
});
it('serializes concurrent cross-domain updates without losing either write', async () => {
await setState(structuredClone(DEFAULT_STATE));
await Promise.all([
updateState((state) => ({ ...state, activeProxyId: 'yakit-mitm' })),
updateState((state) => ({ ...state, floatingPanel: { ...state.floatingPanel, side: 'left' } })),
]);
const state = await getState();
expect(state.activeProxyId).toBe('yakit-mitm');
expect(state.floatingPanel.side).toBe('left');
});
});
+140
View File
@@ -0,0 +1,140 @@
import { browser } from 'wxt/browser';
import type { BridgeRuntimeSession, ExtensionState } from '@/types/models';
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SESSION_STORAGE_KEY,
} from '@/protocol/storage';
interface StorageArea {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
let mutationQueue: Promise<void> = Promise.resolve();
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
export const DEFAULT_STATE: ExtensionState = {
version: 7,
proxyProfiles: [
{ id: 'direct', name: '直接连接', kind: 'direct', bypass: [], builtin: true },
{ id: 'system', name: '系统代理', kind: 'system', bypass: [], builtin: true },
{
id: 'yakit-mitm', name: 'Yakit MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083,
bypass: ['localhost', '127.0.0.1', '<local>'], builtin: true,
},
],
proxyRules: [],
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
activeProxyId: 'direct',
userAgentRules: [],
bridge: {
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false, installationId: crypto.randomUUID(),
},
floatingPanel: {
enabled: true, side: 'right', y: 0.46, displayMode: 'always', siteMode: 'all', siteOrigins: [],
shortcutEnabled: true, autoCollapseFullscreen: true,
},
};
function defaultProfiles() {
return DEFAULT_STATE.proxyProfiles.map((profile) => ({ ...profile, bypass: [...profile.bypass] }));
}
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
for (const profile of value.proxyProfiles || []) profileMap.set(profile.id, { ...profile, bypass: profile.bypass || [] });
const proxyProfiles = [...profileMap.values()];
const routableIds = new Set(proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id));
const proxyRouting = { ...DEFAULT_STATE.proxyRouting, ...value.proxyRouting };
if (!routableIds.has(proxyRouting.defaultProfileId)) proxyRouting.defaultProfileId = 'direct';
return {
...DEFAULT_STATE,
...value,
version: 7,
proxyProfiles,
proxyRules: (value.proxyRules || []).filter((rule) => routableIds.has(rule.proxyProfileId)).map((rule, index) => ({ ...rule, priority: rule.priority || 1_000 - index })),
proxyRouting,
userAgentRules: value.userAgentRules || [],
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge },
floatingPanel: {
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
},
activeGrant: value.activeGrant?.expiresAt && value.activeGrant.expiresAt > Date.now() ? value.activeGrant : undefined,
};
}
export async function getState(): Promise<ExtensionState> {
const localKeys = [PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY];
const sessionPromise: Promise<Record<string, unknown>> = sessionStorage?.get(ACTIVE_SESSION_STORAGE_KEY) || Promise.resolve({});
const [local, session] = await Promise.all([
browser.storage.local.get(localKeys),
sessionPromise,
]);
const state = normalizeState({
...(local[PROXY_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[USER_AGENT_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[BRIDGE_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[FLOATING_UI_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(session[ACTIVE_SESSION_STORAGE_KEY] as Partial<ExtensionState> | undefined),
});
const storedBridge = local[BRIDGE_SETTINGS_STORAGE_KEY] as { bridge?: Partial<ExtensionState['bridge']> } | undefined;
if (!storedBridge?.bridge?.installationId) {
await browser.storage.local.set({
[BRIDGE_SETTINGS_STORAGE_KEY]: { ...storedBridge, bridge: state.bridge },
});
}
return state;
}
export async function getBridgeRuntimeSession(): Promise<BridgeRuntimeSession | undefined> {
if (!sessionStorage) return undefined;
const stored = (await sessionStorage.get(BRIDGE_SESSION_STORAGE_KEY))[BRIDGE_SESSION_STORAGE_KEY];
if (!stored || typeof stored !== 'object') return undefined;
const value = stored as Partial<BridgeRuntimeSession>;
if (!value.sessionId || !value.engineInstanceId || typeof value.updatedAt !== 'number') return undefined;
return value as BridgeRuntimeSession;
}
export async function setBridgeRuntimeSession(value: BridgeRuntimeSession): Promise<void> {
await sessionStorage?.set({ [BRIDGE_SESSION_STORAGE_KEY]: value });
}
export async function setState(input: ExtensionState): Promise<ExtensionState> {
const state = normalizeState(input);
await Promise.all([
browser.storage.local.set({
[PROXY_SETTINGS_STORAGE_KEY]: {
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
proxyRouting: state.proxyRouting, activeProxyId: state.activeProxyId,
},
[USER_AGENT_SETTINGS_STORAGE_KEY]: { userAgentRules: state.userAgentRules },
[BRIDGE_SETTINGS_STORAGE_KEY]: { bridge: state.bridge },
[FLOATING_UI_STORAGE_KEY]: { floatingPanel: state.floatingPanel },
}),
sessionStorage?.set({
[ACTIVE_SESSION_STORAGE_KEY]: { activeGrant: state.activeGrant, handoff: state.handoff },
}) || Promise.resolve(),
]);
return state;
}
export async function updateState(
updater: (current: ExtensionState) => ExtensionState | Promise<ExtensionState>,
): Promise<ExtensionState> {
let resolveResult!: (state: ExtensionState) => void;
let rejectResult!: (error: unknown) => void;
const result = new Promise<ExtensionState>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
mutationQueue = mutationQueue.then(async () => {
try {
resolveResult(await setState(await updater(await getState())));
} catch (error) {
rejectResult(error);
}
});
return result;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope, parseBridgePairingEnvelope, parseCapabilityParams,
} from './bridge';
describe('Bridge v3 protocol', () => {
it('accepts an identified hello_ack', () => {
expect(parseBridgeEnvelope({
type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, version: 'test', capabilities: [],
sessionId: 'session-1', engineIdentityId: 'engine-identity-1', engineInstanceId: 'engine-1', connectionId: 'connection-1', resumed: true,
})).toMatchObject({ type: 'hello_ack', resumed: true });
});
it('rejects mismatched versions and missing identities', () => {
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: 1, capabilities: [] })).toThrow('不兼容');
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, capabilities: [] })).toThrow('engineIdentityId');
});
it('validates engine challenges and pairing responses', () => {
const publicKey = { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' } as const;
expect(parseBridgeEnvelope({
type: 'challenge', protocolVersion: BRIDGE_PROTOCOL_VERSION, engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
challenge: 'challenge-1', signature: 'signature-1', timestamp: Date.now(), publicKey,
})).toMatchObject({ type: 'challenge', engineIdentityId: 'identity-1' });
expect(parseBridgePairingEnvelope({
type: 'pair_pending', protocolVersion: BRIDGE_PROTOCOL_VERSION, requestId: 'request-1', serverNonce: 'server-nonce',
engineIdentityId: 'identity-1', code: '123456', expiresAt: Date.now() + 60_000, publicKey,
})).toMatchObject({ type: 'pair_pending', code: '123456' });
});
it('validates heartbeat and chunk boundaries', () => {
expect(parseBridgeEnvelope({ type: 'pong', id: 'p1', sequence: 3, timestamp: 100 })).toMatchObject({ sequence: 3 });
expect(() => parseBridgeEnvelope({ type: 'ping' })).toThrow('心跳');
expect(parseBridgeEnvelope({
type: 'chunk', transferId: 't1', index: 0, total: 2, data: 'eA==', originalBytes: 2,
})).toMatchObject({ transferId: 't1' });
expect(() => parseBridgeEnvelope({
type: 'chunk', transferId: 't1', index: 2, total: 2, data: 'eA==', originalBytes: 2,
})).toThrow('序号');
});
it('requires explicit Eval mode and caps raw payloads', () => {
expect(parseCapabilityParams('browser.eval', { mode: 'expression', code: 'document.title' })).toMatchObject({ mode: 'expression' });
expect(() => parseCapabilityParams('browser.eval', { code: 'document.title' })).toThrow('mode');
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
});
});
+232
View File
@@ -0,0 +1,232 @@
import * as v from 'valibot';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
export const BRIDGE_PROTOCOL_VERSION = 3;
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
export const BRIDGE_CHUNK_THRESHOLD_BYTES = 512 * 1024;
export const BRIDGE_CHUNK_BYTES = 256 * 1024;
export const BRIDGE_MAX_CHUNK_TRANSFERS = 8;
export const BRIDGE_CHUNK_TIMEOUT_MS = 30_000;
export interface BridgePairingEnvelope {
type: 'pair_request' | 'pair_pending' | 'pair_approved' | 'pair_rejected' | 'pair_expired' | 'pair_error';
protocolVersion?: number;
requestId?: string;
installationId?: string;
client?: string;
version?: string;
nonce?: string;
serverNonce?: string;
publicKey?: BridgePublicKey;
engineIdentityId?: string;
code?: string;
expiresAt?: number;
deviceId?: string;
message?: string;
}
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const optionalTabId = v.optional(tabId);
const optionalFrameId = v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0)));
const optionalDocumentId = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)));
const targetFields = { tabId: optionalTabId, frameId: optionalFrameId, documentId: optionalDocumentId };
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
const capabilityParams = {
'system.ping': v.optional(v.strictObject({})),
'browser.tabs': v.optional(v.strictObject({})),
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
'browser.context': v.optional(v.strictObject({
...targetFields,
includeDom: v.optional(v.boolean()),
includeStorage: v.optional(v.boolean()),
includeCookies: v.optional(v.boolean()),
})),
'browser.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
'browser.node.action': v.pipe(v.strictObject({
...targetFields,
captureId,
nodeId,
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'browser.cookies': v.optional(v.strictObject(targetFields)),
'browser.takeover': v.optional(v.strictObject(targetFields)),
'browser.handoff.request': v.strictObject({
...targetFields,
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
message: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(500)), ''),
}),
'browser.handoff.status': v.optional(v.strictObject({})),
'browser.network.start': v.optional(v.strictObject({
...targetFields,
captureHeaders: v.optional(v.boolean()),
captureBody: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
})),
'browser.network.status': v.optional(v.strictObject(targetFields)),
'browser.network.list': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
})),
'browser.network.clear': v.optional(v.strictObject(targetFields)),
'browser.network.stop': v.optional(v.strictObject(targetFields)),
'browser.network.export': v.strictObject({ ...targetFields, id }),
'browser.network.poc': v.strictObject({ ...targetFields, id }),
'browser.network.analysis': v.strictObject({ ...targetFields, id }),
'browser.observe.start': v.optional(v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
})),
'browser.observe.status': v.optional(v.strictObject(targetFields)),
'browser.observe.list': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
})),
'browser.observe.clear': v.optional(v.strictObject(targetFields)),
'browser.observe.stop': v.optional(v.strictObject(targetFields)),
'browser.invoke': v.strictObject({
...targetFields,
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
args: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(1_000)), []),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'browser.eval': v.strictObject({
...targetFields,
mode: v.picklist(['expression', 'program']),
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'proxy.list': v.optional(v.strictObject({})),
'proxy.switch': v.strictObject({ id }),
} satisfies Record<string, v.GenericSchema>;
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
return issues.map((issue) => {
const path = v.getDotPath(issue);
return `${path ? `${path}: ` : ''}${issue.message}`;
}).join('; ');
}
export function parseCapabilityParams(method: string, input: unknown): Record<string, unknown> {
const schema = capabilityParams[method as keyof typeof capabilityParams];
if (!schema) throw new Error(`不支持的 Bridge 方法: ${method}`);
const result = v.safeParse(schema, input);
if (!result.success) throw new Error(`Bridge 方法 ${method} 的参数无效: ${issueMessage(result.issues)}`);
return (result.output || {}) as Record<string, unknown>;
}
export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
let input = raw;
if (typeof raw === 'string') {
if (new TextEncoder().encode(raw).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
input = JSON.parse(raw) as unknown;
} else {
const encoded = JSON.stringify(raw);
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 消息必须是对象');
const message = input as Record<string, unknown>;
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
if (message.type === 'challenge') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
for (const key of ['engineIdentityId', 'engineInstanceId', 'challenge', 'signature'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge ${key} 无效`);
}
if (!Number.isSafeInteger(message.timestamp) || Number(message.timestamp) <= 0) throw new Error('Bridge challenge 时间无效');
parseBridgePublicKey(message.publicKey);
return message as unknown as BridgeEnvelope;
}
if (message.type === 'hello_ack') {
if (!Number.isSafeInteger(message.protocolVersion) || message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) {
throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
}
if (message.version !== undefined && typeof message.version !== 'string') throw new Error('Bridge 引擎版本无效');
if (!Array.isArray(message.capabilities) || message.capabilities.some((item) => typeof item !== 'string')) {
throw new Error('Bridge 能力列表无效');
}
for (const key of ['engineIdentityId', 'engineInstanceId', 'connectionId', 'sessionId'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 160) throw new Error(`Bridge ${key} 无效`);
}
if (message.resumed !== undefined && typeof message.resumed !== 'boolean') throw new Error('Bridge resumed 状态无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'request') {
if (typeof message.id !== 'string' || !message.id || message.id.length > 160) throw new Error('Bridge 请求 ID 无效');
if (typeof message.method !== 'string' || !message.method || message.method.length > 160) throw new Error('Bridge 请求方法无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'ping' || message.type === 'pong' || message.type === 'cancel') {
if (message.id !== undefined && typeof message.id !== 'string') throw new Error('Bridge 心跳 ID 无效');
if (message.type === 'cancel' && !message.id) throw new Error('Bridge cancel 缺少请求 ID');
if ((message.type === 'ping' || message.type === 'pong') && (!Number.isSafeInteger(message.sequence) || typeof message.timestamp !== 'number')) throw new Error('Bridge 心跳序号或时间无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'chunk') {
if (typeof message.transferId !== 'string' || !message.transferId || message.transferId.length > 160) throw new Error('Bridge chunk transferId 无效');
if (!Number.isSafeInteger(message.index) || !Number.isSafeInteger(message.total) || Number(message.index) < 0 || Number(message.total) < 1 || Number(message.total) > 128 || Number(message.index) >= Number(message.total)) throw new Error('Bridge chunk 序号无效');
if (typeof message.data !== 'string' || message.data.length > 384 * 1024) throw new Error('Bridge chunk 数据无效');
if (!Number.isSafeInteger(message.originalBytes) || Number(message.originalBytes) < 1 || Number(message.originalBytes) > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge chunk 原始大小无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'response') {
if (message.id !== undefined && (typeof message.id !== 'string' || !message.id || message.id.length > 160)) {
throw new Error('Bridge 响应 ID 无效');
}
if (!message.id && !message.error) throw new Error('Bridge 响应缺少 ID');
if (message.error !== undefined) {
if (!message.error || typeof message.error !== 'object') throw new Error('Bridge 响应错误对象无效');
const responseError = message.error as Record<string, unknown>;
if (typeof responseError.code !== 'string' || typeof responseError.message !== 'string') throw new Error('Bridge 响应错误格式无效');
}
return message as unknown as BridgeEnvelope;
}
throw new Error(`不支持的 Bridge 消息类型: ${message.type}`);
}
function parseBridgePublicKey(input: unknown): BridgePublicKey {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 公钥无效');
const key = input as Record<string, unknown>;
if (key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string') {
throw new Error('Bridge 公钥必须使用 ECDSA P-256');
}
if (!key.x || !key.y || key.x.length > 128 || key.y.length > 128) throw new Error('Bridge 公钥坐标无效');
return key as unknown as BridgePublicKey;
}
export function parseBridgePairingEnvelope(raw: unknown): BridgePairingEnvelope {
let input = raw;
if (typeof raw === 'string') {
if (new TextEncoder().encode(raw).byteLength > 32 * 1024) throw new Error('Bridge 配对消息超过 32 KiB 限制');
input = JSON.parse(raw) as unknown;
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 配对消息必须是对象');
const message = input as Record<string, unknown>;
const allowed = ['pair_pending', 'pair_approved', 'pair_rejected', 'pair_expired', 'pair_error'];
if (typeof message.type !== 'string' || !allowed.includes(message.type)) throw new Error('Bridge 配对消息类型无效');
if (message.message !== undefined && (typeof message.message !== 'string' || message.message.length > 1_024)) throw new Error('Bridge 配对消息文本无效');
if (message.type === 'pair_pending') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error('Bridge 配对协议版本不兼容');
for (const key of ['requestId', 'serverNonce', 'engineIdentityId', 'code'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
}
if (!/^\d{6}$/.test(String(message.code))) throw new Error('Bridge 配对验证码无效');
if (!Number.isSafeInteger(message.expiresAt) || Number(message.expiresAt) <= Date.now()) throw new Error('Bridge 配对申请已经过期');
parseBridgePublicKey(message.publicKey);
}
if (message.type === 'pair_approved') {
for (const key of ['requestId', 'deviceId', 'engineIdentityId'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
}
parseBridgePublicKey(message.publicKey);
}
return message as unknown as BridgePairingEnvelope;
}
+80
View File
@@ -0,0 +1,80 @@
import type { CapabilityScope } from '@/types/models';
export const BRIDGE_CAPABILITIES = [
'system.ping',
'browser.tabs',
'browser.frames',
'browser.context',
'browser.node.inspect',
'browser.node.action',
'browser.cookies',
'browser.takeover',
'browser.handoff.request',
'browser.handoff.status',
'browser.network.start',
'browser.network.status',
'browser.network.list',
'browser.network.clear',
'browser.network.stop',
'browser.network.export',
'browser.network.poc',
'browser.network.analysis',
'browser.observe.start',
'browser.observe.status',
'browser.observe.list',
'browser.observe.clear',
'browser.observe.stop',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ? ['browser.invoke', 'browser.eval'] : []),
'proxy.list',
'proxy.switch',
] as const;
export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.tabs.read',
'browser.dom.read',
'browser.storage.read',
'browser.cookies.read',
'browser.network.read',
'browser.observation.read',
];
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
...READ_CAPABILITY_SCOPES,
'browser.dom.write',
'browser.tab.activate',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
: []),
'browser.human.takeover',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'browser.proxy.read',
'browser.proxy.write',
];
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.tabs.read': '标签页列表',
'browser.dom.read': '页面 DOM',
'browser.dom.write': '操作页面元素',
'browser.storage.read': '页面 Storage',
'browser.cookies.read': 'Cookie',
'browser.tab.activate': '切到前台',
'browser.page.invoke': '调用页面函数',
'browser.page.eval.expression': '执行页面表达式',
'browser.page.eval.program': '执行页面程序',
'browser.human.takeover': '人工接管',
'browser.network.read': '读取网络摘要',
'browser.network.capture': '控制网络捕获',
'browser.network.sensitive.read': '读取请求头与请求体',
'browser.observation.read': '读取页面行为观测',
'browser.observation.control': '控制页面行为观测',
'browser.observation.sensitive.read': '读取观测值预览',
'browser.proxy.read': '读取代理',
'browser.proxy.write': '切换代理',
};
export function isControlScopeSet(scopes: readonly CapabilityScope[]): boolean {
return scopes.some((scope) => !READ_CAPABILITY_SCOPES.includes(scope));
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseExtensionRequest } from './extension';
describe('extension request schemas', () => {
it('rejects unknown fields', () => {
expect(() => parseExtensionRequest({ action: 'panel.update', payload: { enabled: true, unexpected: true } })).toThrow('unexpected');
});
it('accepts split panel policy and explicit Eval mode', () => {
expect(parseExtensionRequest({
action: 'panel.update',
payload: { displayMode: 'active-task', siteMode: 'denylist', siteOrigins: ['https://example.test'] },
}).action).toBe('panel.update');
expect(parseExtensionRequest({
action: 'context.eval', payload: { mode: 'program', code: '1 + 1', timeoutMs: 500 },
}).action).toBe('context.eval');
});
});
+292
View File
@@ -0,0 +1,292 @@
import * as v from 'valibot';
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
import type { CapabilityScope } from '@/types/models';
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const shortText = v.pipe(v.string(), v.trim(), v.maxLength(240));
const url = v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const frameId = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
const targetFields = { tabId: v.optional(tabId), frameId: v.optional(frameId), documentId: v.optional(documentId) };
const port = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(65_535));
const proxyHost = v.pipe(
v.string(),
v.trim(),
v.minLength(1),
v.maxLength(253),
v.regex(/^[a-zA-Z0-9._:[\]-]+$/, '代理主机只能包含主机名或 IP 地址字符'),
);
const httpUrl = v.pipe(
url,
v.check((value) => ['http:', 'https:'].includes(new URL(value).protocol), '只允许 HTTP(S) URL'),
);
const noPayload = v.optional(v.undefined_());
const stringList = (maxItems = 200, maxLength = 2_048) => v.pipe(
v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(maxLength))),
v.maxLength(maxItems),
);
const proxyProfile = v.pipe(v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
kind: v.picklist(['direct', 'system', 'fixed_servers', 'pac_script']),
host: v.optional(proxyHost),
port: v.optional(port),
scheme: v.optional(v.picklist(['http', 'https', 'socks4', 'socks5'])),
pacUrl: v.optional(httpUrl),
pacScript: v.optional(v.pipe(v.string(), v.maxLength(1_000_000))),
bypass: stringList(500, 2_048),
builtin: v.optional(v.boolean()),
authEnabled: v.optional(v.boolean()),
authUsername: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
}), v.check((profile) => {
if (profile.kind === 'fixed_servers') return Boolean(profile.host && profile.port && profile.scheme);
if (profile.kind === 'pac_script') return Boolean(profile.pacUrl || profile.pacScript?.trim());
return true;
}, '代理配置缺少当前类型所需的主机、端口或 PAC 内容'));
const proxyRule = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
patterns: stringList(500, 2_048),
proxyProfileId: id,
priority: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000)),
});
const proxyRouting = v.strictObject({
defaultProfileId: id,
failMode: v.picklist(['open', 'closed']),
});
const proxyConfiguration = v.strictObject({
version: v.literal(1),
profiles: v.pipe(v.array(proxyProfile), v.minLength(1), v.maxLength(500)),
rules: v.pipe(v.array(proxyRule), v.maxLength(5_000)),
routing: proxyRouting,
});
const userAgentRule = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
userAgent: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
domains: stringList(500, 253),
});
const bridgeConfig = v.strictObject({
transport: v.picklist(['native', 'websocket']),
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
autoConnect: v.boolean(),
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
pairedEngine: v.optional(v.strictObject({
engineIdentityId: id,
deviceId: id,
publicKey: v.strictObject({
kty: v.literal('EC'),
crv: v.literal('P-256'),
x: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
y: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
}),
pairedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(1)),
})),
});
const partitionKey = v.strictObject({
topLevelSite: v.optional(httpUrl),
hasCrossSiteAncestor: v.optional(v.boolean()),
});
const cookieInput = v.strictObject({
url,
name: v.pipe(v.string(), v.maxLength(4_096)),
value: v.pipe(v.string(), v.maxLength(64 * 1_024)),
domain: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(253))),
path: v.optional(v.pipe(v.string(), v.maxLength(4_096))),
secure: v.optional(v.boolean()),
httpOnly: v.optional(v.boolean()),
sameSite: v.optional(v.picklist(['no_restriction', 'lax', 'strict', 'unspecified'])),
expirationDate: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0))),
storeId: v.optional(shortText),
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
partitionKey: v.optional(partitionKey),
});
const cookieRemoveInput = v.strictObject({
url,
name: v.pipe(v.string(), v.maxLength(4_096)),
storeId: v.optional(shortText),
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
partitionKey: v.optional(partitionKey),
});
const contextOptions = {
includeStorage: v.optional(v.boolean()),
includeCookies: v.optional(v.boolean()),
includeDom: v.optional(v.boolean()),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
};
const capabilityScopes: readonly CapabilityScope[] = [
'browser.tabs.read',
'browser.dom.read',
'browser.dom.write',
'browser.storage.read',
'browser.cookies.read',
'browser.tab.activate',
'browser.page.invoke',
'browser.page.eval.expression',
'browser.page.eval.program',
'browser.human.takeover',
'browser.network.read',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'browser.proxy.read',
'browser.proxy.write',
];
const payloadSchemas = {
'state.get': noPayload,
'tab.active': noPayload,
'tab.get': v.strictObject({ tabId }),
'tab.list': noPayload,
'frame.list': v.strictObject({ tabId }),
'proxy.save': proxyProfile,
'proxy.delete': v.strictObject({ id }),
'proxy.switch': v.strictObject({ id }),
'proxy.rule.save': proxyRule,
'proxy.rule.delete': v.strictObject({ id }),
'proxy.rules.apply': noPayload,
'proxy.rules.preview': v.strictObject({ url: httpUrl }),
'proxy.rules.compile': noPayload,
'proxy.rules.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(5_000)) }),
'proxy.rules.settings': proxyRouting,
'proxy.rules.stats': noPayload,
'proxy.rules.stats.clear': noPayload,
'proxy.auth.set': v.strictObject({ profileId: id, password: v.pipe(v.string(), v.maxLength(4_096)) }),
'proxy.auth.status': v.strictObject({ profileId: id }),
'proxy.config.export': noPayload,
'proxy.config.import': v.strictObject({ configuration: proxyConfiguration }),
'cookie.list': v.strictObject({ url }),
'cookie.set': cookieInput,
'cookie.remove': cookieRemoveInput,
'cookie.removeMany': v.strictObject({ cookies: v.pipe(v.array(cookieRemoveInput), v.minLength(1), v.maxLength(1_000)) }),
'cookie.import': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
'cookie.export': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
'ua.save': userAgentRule,
'ua.delete': v.strictObject({ id }),
'ua.apply': noPayload,
'context.capture': v.strictObject(contextOptions),
'context.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
'context.node.action': v.pipe(v.strictObject({
...targetFields,
captureId,
nodeId,
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'context.invoke': v.strictObject({
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
args: v.pipe(v.array(v.unknown()), v.maxLength(1_000)),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'context.eval': v.strictObject({
mode: v.picklist(['expression', 'program']),
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'panel.update': v.strictObject({
enabled: v.optional(v.boolean()),
side: v.optional(v.picklist(['left', 'right'])),
y: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(1))),
displayMode: v.optional(v.picklist(['always', 'active-task'])),
siteMode: v.optional(v.picklist(['all', 'allowlist', 'denylist'])),
siteOrigins: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.url(), v.maxLength(2_048))), v.maxLength(500))),
shortcutEnabled: v.optional(v.boolean()),
autoCollapseFullscreen: v.optional(v.boolean()),
}),
'grant.create': v.strictObject({
targets: v.pipe(v.array(v.strictObject({ tabId, frameId })), v.minLength(1), v.maxLength(256)),
scopes: v.pipe(v.array(v.picklist(capabilityScopes)), v.minLength(1), v.maxLength(capabilityScopes.length)),
durationMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(24 * 60)),
taskId: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))),
}),
'grant.revoke': noPayload,
'handoff.resolve': v.strictObject({ id, outcome: v.picklist(['completed', 'cancelled']) }),
'network.capture.start': v.strictObject({
...targetFields,
captureHeaders: v.optional(v.boolean()),
captureBody: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
}),
'network.capture.status': v.strictObject(targetFields),
'network.capture.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
'network.capture.clear': v.strictObject(targetFields),
'network.capture.stop': v.strictObject(targetFields),
'network.capture.export': v.strictObject({ ...targetFields, id }),
'network.capture.send': v.strictObject({ ...targetFields, id }),
'network.capture.poc': v.strictObject({ ...targetFields, id }),
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
'observation.start': v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
}),
'observation.status': v.strictObject(targetFields),
'observation.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
'observation.clear': v.strictObject(targetFields),
'observation.stop': v.strictObject(targetFields),
'audit.list': v.strictObject({ limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'audit.clear': noPayload,
'agent.runtime.get': noPayload,
'agent.pause': noPayload,
'agent.resume': noPayload,
'agent.actions.clear': noPayload,
'policy.status': noPayload,
'diagnostics.export': noPayload,
'metrics.get': noPayload,
'metrics.reset': noPayload,
'bridge.config.save': bridgeConfig,
'bridge.pair': noPayload,
'bridge.pair.cancel': noPayload,
'bridge.pair.status': noPayload,
'bridge.unpair': noPayload,
'bridge.connect': noPayload,
'bridge.disconnect': noPayload,
'bridge.status': noPayload,
} satisfies Record<ExtensionAction, v.GenericSchema>;
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
return issues.map((issue) => {
const path = v.getDotPath(issue);
return `${path ? `${path}: ` : ''}${issue.message}`;
}).join('; ');
}
export function parseExtensionRequest(input: unknown): ExtensionRequest {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('扩展消息必须是对象');
const record = input as Record<string, unknown>;
if (Object.keys(record).some((key) => key !== 'action' && key !== 'payload')) throw new Error('扩展消息包含未知字段');
if (typeof record.action !== 'string' || !(record.action in payloadSchemas)) throw new Error('未知扩展操作');
const action = record.action as ExtensionAction;
const result = v.safeParse(payloadSchemas[action], record.payload);
if (!result.success) throw new Error(`操作 ${action} 的参数无效: ${issueMessage(result.issues)}`);
return { action, payload: result.output } as ExtensionRequest;
}
+27
View File
@@ -0,0 +1,27 @@
export const PROXY_SETTINGS_STORAGE_KEY = 'settings.proxy.v1';
export const USER_AGENT_SETTINGS_STORAGE_KEY = 'settings.user-agent.v1';
export const BRIDGE_SETTINGS_STORAGE_KEY = 'settings.bridge.v2';
export const FLOATING_UI_STORAGE_KEY = 'ui.floating-panel.v1';
export const ACTIVE_SESSION_STORAGE_KEY = 'session.browser-agent.v1';
export const BRIDGE_SESSION_STORAGE_KEY = 'session.bridge.v1';
export const AGENT_RUNTIME_STORAGE_KEY = 'session.agent-runtime.v1';
export const STATE_STORAGE_KEYS = [
PROXY_SETTINGS_STORAGE_KEY,
USER_AGENT_SETTINGS_STORAGE_KEY,
BRIDGE_SETTINGS_STORAGE_KEY,
FLOATING_UI_STORAGE_KEY,
ACTIVE_SESSION_STORAGE_KEY,
BRIDGE_SESSION_STORAGE_KEY,
AGENT_RUNTIME_STORAGE_KEY,
] as const;
export function isStateStorageChange(changes: Record<string, unknown>): boolean {
return STATE_STORAGE_KEYS.some((key) => key in changes);
}
export const AUDIT_STORAGE_KEY = 'yakit-audit-log-v1';
export const NETWORK_CAPTURE_STORAGE_KEY = 'yakit-network-capture-v1';
export const CONTEXT_DIGEST_STORAGE_KEY = 'yakit-context-digests-v1';
export const PAGE_LIFECYCLE_STORAGE_KEY = 'yakit-page-lifecycle-v1';
export const PROXY_AUTH_STORAGE_KEY = 'yakit-proxy-auth-v1';
export const PROXY_STATS_STORAGE_KEY = 'yakit-proxy-stats-v1';
export const RUNTIME_METRICS_STORAGE_KEY = 'runtime.metrics.v1';
+17
View File
@@ -0,0 +1,17 @@
export class ExtensionError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ExtensionError';
}
}
export function errorCode(error: unknown): string {
return error instanceof ExtensionError ? error.code : 'request_failed';
}
export function isDeniedErrorCode(code: string): boolean {
return ['permission_denied', 'grant_expired', 'target_denied', 'origin_changed', 'stale_document'].includes(code);
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOpaqueId } from './id';
describe('createOpaqueId', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses getRandomValues without requiring randomUUID', () => {
vi.stubGlobal('crypto', {
getRandomValues<T extends ArrayBufferView>(value: T): T {
new Uint8Array(value.buffer, value.byteOffset, value.byteLength).fill(0xab);
return value;
},
});
expect(createOpaqueId('request')).toBe(`request-${'ab'.repeat(16)}`);
});
it('remains unique when the crypto implementation is unavailable', () => {
vi.stubGlobal('crypto', { getRandomValues: () => { throw new Error('unavailable'); } });
const first = createOpaqueId('request');
const second = createOpaqueId('request');
expect(first).not.toBe(second);
expect(first).toMatch(/^request-[a-z0-9]+-[a-z0-9]+$/);
});
});
+20
View File
@@ -0,0 +1,20 @@
let fallbackSequence = 0;
function randomHex(byteLength: number): string | undefined {
try {
const cryptoApi = globalThis.crypto;
if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return undefined;
const bytes = cryptoApi.getRandomValues(new Uint8Array(byteLength));
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
} catch {
return undefined;
}
}
export function createOpaqueId(prefix: string): string {
const entropy = randomHex(16);
if (entropy) return `${prefix}-${entropy}`;
fallbackSequence = (fallbackSequence + 1) % Number.MAX_SAFE_INTEGER;
return `${prefix}-${Date.now().toString(36)}-${fallbackSequence.toString(36)}`;
}
+5 -57
View File
@@ -1,58 +1,6 @@
:root {
--yakit-primary: #F28B44;
--yakit-primary-hover: #f4a061;
--yakit-primary-active: #e87633;
--yakit-primary-5: #fff5eb;
--yakit-primary-10: rgba(242, 139, 68, 0.1);
/* 添加其他全局变量 */
--border-color: #f0f0f0;
--text-color: #333;
--icon-color: #666;
/* 菜单相关变量 */
--menu-item-height: 28px;
--menu-padding: 4px;
--menu-width: 180px;
}
@import './tokens.css';
@import './ui.css';
.ant-btn-primary {
background-color: var(--yakit-primary) !important;
}
.ant-btn-primary:hover {
background-color: var(--yakit-primary-hover) !important;
}
.ant-btn-primary:active {
background-color: var(--yakit-primary-active) !important;
}
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
background-color: var(--yakit-primary-5) !important;
}
.ant-select-focused .ant-select-selector,
.ant-input-focused,
.ant-input:focus,
.ant-input-number-focused,
.ant-input-number:focus {
border-color: var(--yakit-primary) !important;
box-shadow: 0 0 0 2px var(--yakit-primary-10) !important;
}
.ant-btn:not(.ant-btn-primary):hover {
color: var(--yakit-primary) !important;
border-color: var(--yakit-primary) !important;
}
/* 移除所有滚动条 */
::-webkit-scrollbar {
display: none;
}
/* 确保所有内容都在视口内 */
html, body {
overflow: hidden;
height: fit-content;
}
html { color-scheme: light; background: var(--background); }
html[data-theme='dark'] { color-scheme: dark; }
body { margin: 0; font-family: var(--font-sans); font-size: var(--text-md); color: var(--foreground); background: var(--background); }
+72
View File
@@ -0,0 +1,72 @@
:root {
color-scheme: light;
--background: #f3f4f6;
--foreground: #1d232a;
--surface: #ffffff;
--surface-subtle: #eceef1;
--surface-strong: #1d232b;
--muted: #68727d;
--muted-strong: #474f59;
--border: #e1e4e8;
--border-strong: #c8cfd6;
--primary: #ee7815;
--primary-hover: #da6a0e;
--primary-active: #c25e0c;
--primary-contrast: #ffffff;
--primary-soft: #fdf0e1;
--primary-text: #b54f08;
--primary-strong: #b54f08;
--primary-strong-hover: #9e4607;
--primary-on-strong: #ffffff;
--success: #1e7f52;
--success-soft: #e4f3eb;
--warning: #94650d;
--warning-soft: #fcf2d9;
--danger: #bf3d3d;
--danger-soft: #fbeaea;
--focus: rgba(238, 120, 21, .28);
--shadow-sm: 0 1px 2px rgba(20, 26, 32, .05), 0 1px 4px rgba(20, 26, 32, .04);
--shadow-md: 0 2px 6px rgba(20, 26, 32, .06), 0 12px 32px rgba(20, 26, 32, .12);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-md: 13px;
--text-lg: 14px;
--text-xl: 16px;
--text-2xl: 20px;
}
[data-theme='dark'] {
color-scheme: dark;
--background: #0e1116;
--foreground: #e2e7ec;
--surface: #161b21;
--surface-subtle: #1e242c;
--surface-strong: #0c0f14;
--muted: #8a949f;
--muted-strong: #b2bcc5;
--border: #262d36;
--border-strong: #3a434e;
--primary: #f5832a;
--primary-hover: #ff9142;
--primary-active: #da6a0e;
--primary-contrast: #1a1108;
--primary-soft: #2c1f12;
--primary-text: #f7a15c;
--primary-strong: #f5832a;
--primary-strong-hover: #ff9142;
--primary-on-strong: #201205;
--success: #45b981;
--success-soft: #122a1f;
--warning: #d9a441;
--warning-soft: #2c2311;
--danger: #e06e6e;
--danger-soft: #2f1b1b;
--focus: rgba(245, 131, 42, .4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .35);
--shadow-md: 0 2px 6px rgba(0, 0, 0, .35), 0 12px 32px rgba(0, 0, 0, .5);
}
+101
View File
@@ -0,0 +1,101 @@
* { box-sizing: border-box; }
button, input, select, textarea { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; }
.ui-button {
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--text-md);
font-weight: 600;
line-height: 1;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.ui-button:focus-visible, .ui-switch:focus-visible, .ui-tabs-trigger:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus);
}
.ui-button--primary { border-color: var(--primary-strong); background: var(--primary-strong); color: var(--primary-on-strong); }
.ui-button--primary:hover { border-color: var(--primary-strong-hover); background: var(--primary-strong-hover); }
.ui-button--secondary { border-color: var(--border-strong); background: var(--surface); color: var(--foreground); }
.ui-button--secondary:hover { border-color: var(--muted); background: var(--surface-subtle); }
.ui-button--ghost { background: transparent; color: var(--muted-strong); }
.ui-button--ghost:hover { background: var(--surface-subtle); color: var(--foreground); }
.ui-button--danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--surface)); background: var(--surface); color: var(--danger); }
.ui-button--danger:hover { background: var(--danger-soft); }
.ui-button:disabled { border-color: var(--border); background: var(--surface-subtle); color: var(--muted); }
.ui-button--sm { height: 30px; padding: 0 10px; font-size: var(--text-sm); }
.ui-button--icon { width: 34px; height: 34px; padding: 0; }
.ui-switch {
position: relative;
width: 40px;
height: 22px;
flex: 0 0 auto;
padding: 0;
border: 0;
border-radius: 11px;
background: var(--border-strong);
transition: background-color .16s ease;
}
.ui-switch[data-state='checked'] { background: var(--primary); }
.ui-switch:disabled { opacity: .45; }
.ui-switch__thumb {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-sm);
transform: translateX(3px);
transition: transform .16s ease;
}
.ui-switch__thumb[data-state='checked'] { transform: translateX(21px); }
.ui-tabs-list { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-subtle); }
.ui-tabs-trigger { min-width: 72px; height: 30px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: var(--text-sm); font-weight: 600; }
.ui-tabs-trigger[data-state='active'] { background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-sm); }
.ui-tabs-content:focus-visible { outline: none; }
.ui-tabs-content[hidden] { display: none !important; }
.ui-tooltip { z-index: 2147483647; max-width: 260px; padding: 7px 9px; border-radius: 6px; background: #202428; color: #fff; font-family: var(--font-sans); font-size: var(--text-sm); line-height: 1.4; box-shadow: var(--shadow-md); }
.ui-tooltip__arrow { fill: #202428; }
.ui-field { min-width: 0; display: grid; gap: 6px; }
.ui-field__label { color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; }
.ui-field__hint { color: var(--muted); font-size: var(--text-sm); line-height: 1.45; }
input, select, textarea {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--foreground);
font-size: var(--text-md);
}
input, select { height: 36px; padding: 0 11px; }
textarea { min-height: 78px; padding: 9px 11px; line-height: 1.5; resize: vertical; }
input::placeholder, textarea::placeholder { color: var(--muted); }
.ui-badge { min-height: 22px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
.product-brand { min-width: 0; display: flex; align-items: center; gap: 11px; }
.product-brand__art { width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; }
.yak-mark { width: 40px; height: 40px; display: block; object-fit: contain; }
.yakit-mark { display: block; object-fit: contain; }
.product-brand__copy { min-width: 0; display: grid; gap: 2px; }
.product-brand__copy strong { overflow: hidden; color: inherit; font-size: var(--text-md); line-height: 18px; white-space: nowrap; text-overflow: ellipsis; }
.product-brand__copy small { overflow: hidden; color: #929aa1; font-size: var(--text-xs); line-height: 14px; white-space: nowrap; text-overflow: ellipsis; }
.product-brand--compact .product-brand__art { width: 34px; height: 34px; }
.product-brand--compact .yak-mark { width: 34px; height: 34px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
-19
View File
@@ -1,19 +0,0 @@
export const ProxyActionType = {
SET_PROXY_CONFIG: "SET_PROXY_CONFIG",
CLEAR_PROXY_CONFIG: "CLEAR_PROXY_CONFIG",
GET_PROXY_STATUS: "GET_PROXY_STATUS",
CLEAR_PROXY_LOGS: "CLEAR_PROXY_LOGS",
GET_PROXY_LOGS: "GET_PROXY_LOGS",
GET_PROXY_CONFIGS: "GET_PROXY_CONFIGS",
ADD_PROXY_CONFIG: "ADD_PROXY_CONFIG",
UPDATE_PROXY_CONFIG: "UPDATE_PROXY_CONFIG",
SWITCH_PROXY: "SWITCH_PROXY",
} as const;
export const ContentActionType = {
PROXY_CONFIGS_UPDATED: "PROXY_CONFIGS_UPDATED",
PROXY_STATUS_CHANGED: "PROXY_STATUS_CHANGED",
TRIGGER_ADD_PROXY: "TRIGGER_ADD_PROXY",
}
export type ProxyActionType = typeof ProxyActionType[keyof typeof ProxyActionType];
+170
View File
@@ -0,0 +1,170 @@
import type {
ActiveTabInfo,
AgentRuntime,
AuditEvent,
BrowserRequestAnalysisBundle,
BridgeStatus,
BridgeConfig,
BridgePairingStatus,
BridgePublicKey,
BrowserCookie,
CookieInput,
CookieImportResult,
CookieRemoveInput,
CookieTransferFormat,
ExtensionState,
EnterprisePolicyStatus,
DiagnosticsBundle,
RuntimeMetrics,
GrantCreateInput,
NetworkCaptureStatus,
NetworkRequestExport,
NetworkRequestRecord,
PageObservationRecord,
PageObservationStatus,
PageContext,
PageContextOptions,
PageEvalRequest,
PageEvalResult,
PageFrameSummary,
PageNodeAction,
PageNodeActionResult,
PageNodeDetails,
ProxyProfile,
ProxyConfiguration,
ProxyRule,
ProxyRulePreview,
ProxyRuleStats,
ProxyRoutingSettings,
UserAgentRule,
YakPocGenerateResult,
YakitFuzzerOpenResult,
} from './models';
export interface ExtensionRequestMap {
'state.get': { input: undefined; output: ExtensionState };
'tab.active': { input: undefined; output: ActiveTabInfo };
'tab.get': { input: { tabId: number }; output: ActiveTabInfo };
'tab.list': { input: undefined; output: ActiveTabInfo[] };
'frame.list': { input: { tabId: number }; output: PageFrameSummary[] };
'proxy.save': { input: ProxyProfile; output: ExtensionState };
'proxy.delete': { input: { id: string }; output: ExtensionState };
'proxy.switch': { input: { id: string }; output: ExtensionState };
'proxy.rule.save': { input: ProxyRule; output: ExtensionState };
'proxy.rule.delete': { input: { id: string }; output: ExtensionState };
'proxy.rules.apply': { input: undefined; output: ExtensionState };
'proxy.rules.preview': { input: { url: string }; output: ProxyRulePreview };
'proxy.rules.compile': { input: undefined; output: string };
'proxy.rules.reorder': { input: { ids: string[] }; output: ExtensionState };
'proxy.rules.settings': { input: ProxyRoutingSettings; output: ExtensionState };
'proxy.rules.stats': { input: undefined; output: ProxyRuleStats[] };
'proxy.rules.stats.clear': { input: undefined; output: undefined };
'proxy.auth.set': { input: { profileId: string; password: string }; output: { configured: boolean } };
'proxy.auth.status': { input: { profileId: string }; output: { configured: boolean } };
'proxy.config.export': { input: undefined; output: ProxyConfiguration };
'proxy.config.import': { input: { configuration: ProxyConfiguration }; output: ExtensionState };
'cookie.list': { input: { url: string }; output: BrowserCookie[] };
'cookie.set': { input: CookieInput; output: BrowserCookie };
'cookie.remove': { input: CookieRemoveInput; output: undefined };
'cookie.removeMany': { input: { cookies: CookieRemoveInput[] }; output: { removed: number; failed: number } };
'cookie.import': { input: { url: string; format: CookieTransferFormat; text: string }; output: CookieImportResult };
'cookie.export': { input: { url: string; format: CookieTransferFormat; includeValues: boolean }; output: string };
'ua.save': { input: UserAgentRule; output: ExtensionState };
'ua.delete': { input: { id: string }; output: ExtensionState };
'ua.apply': { input: undefined; output: ExtensionState };
'context.capture': { input: PageContextOptions & { tabId?: number; frameId?: number; documentId?: string }; output: PageContext };
'context.node.inspect': { input: { captureId: string; nodeId: string; tabId?: number; frameId?: number; documentId?: string }; output: PageNodeDetails };
'context.node.action': { input: { captureId: string; nodeId: string; action: PageNodeAction; value?: string; tabId?: number; frameId?: number; documentId?: string }; output: PageNodeActionResult };
'context.invoke': { input: { path: string; args: unknown[]; tabId?: number; frameId?: number; documentId?: string; timeoutMs?: number }; output: PageEvalResult };
'context.eval': { input: PageEvalRequest; output: PageEvalResult };
'panel.update': { input: {
enabled?: boolean; side?: 'left' | 'right'; y?: number;
displayMode?: 'always' | 'active-task'; siteMode?: 'all' | 'allowlist' | 'denylist'; siteOrigins?: string[];
shortcutEnabled?: boolean; autoCollapseFullscreen?: boolean;
}; output: ExtensionState };
'grant.create': { input: GrantCreateInput; output: ExtensionState };
'grant.revoke': { input: undefined; output: ExtensionState };
'handoff.resolve': { input: { id: string; outcome: 'completed' | 'cancelled' }; output: ExtensionState };
'network.capture.start': { input: { tabId?: number; frameId?: number; documentId?: string; captureHeaders?: boolean; captureBody?: boolean; maxEntries?: number; maxBodyBytes?: number }; output: NetworkCaptureStatus };
'network.capture.status': { input: { tabId?: number; frameId?: number; documentId?: string }; output: NetworkCaptureStatus };
'network.capture.list': { input: { tabId?: number; frameId?: number; documentId?: string; limit?: number }; output: NetworkRequestRecord[] };
'network.capture.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: NetworkCaptureStatus };
'network.capture.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: NetworkCaptureStatus };
'network.capture.export': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: NetworkRequestExport };
'network.capture.send': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakitFuzzerOpenResult };
'network.capture.poc': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakPocGenerateResult };
'network.capture.analysis': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: BrowserRequestAnalysisBundle };
'observation.start': { input: { tabId?: number; frameId?: number; documentId?: string; captureValues?: boolean; maxEntries?: number; maxValueBytes?: number }; output: PageObservationStatus };
'observation.status': { input: { tabId?: number; frameId?: number; documentId?: string }; output: PageObservationStatus };
'observation.list': { input: { tabId?: number; frameId?: number; documentId?: string; limit?: number }; output: PageObservationRecord[] };
'observation.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: PageObservationStatus };
'observation.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: PageObservationStatus };
'audit.list': { input: { limit?: number }; output: AuditEvent[] };
'audit.clear': { input: undefined; output: undefined };
'agent.runtime.get': { input: undefined; output: AgentRuntime };
'agent.pause': { input: undefined; output: AgentRuntime };
'agent.resume': { input: undefined; output: AgentRuntime };
'agent.actions.clear': { input: undefined; output: AgentRuntime };
'policy.status': { input: undefined; output: EnterprisePolicyStatus };
'diagnostics.export': { input: undefined; output: DiagnosticsBundle };
'metrics.get': { input: undefined; output: RuntimeMetrics };
'metrics.reset': { input: undefined; output: RuntimeMetrics };
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
'bridge.pair': { input: undefined; output: BridgePairingStatus };
'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus };
'bridge.pair.status': { input: undefined; output: BridgePairingStatus };
'bridge.unpair': { input: undefined; output: ExtensionState };
'bridge.connect': { input: undefined; output: BridgeStatus };
'bridge.disconnect': { input: undefined; output: BridgeStatus };
'bridge.status': { input: undefined; output: BridgeStatus };
}
export type ExtensionAction = keyof ExtensionRequestMap;
export type RequestInput<A extends ExtensionAction> = ExtensionRequestMap[A]['input'];
export type RequestOutput<A extends ExtensionAction> = ExtensionRequestMap[A]['output'];
export type ExtensionRequest = {
[A in ExtensionAction]: undefined extends RequestInput<A>
? { action: A; payload?: RequestInput<A> }
: { action: A; payload: RequestInput<A> }
}[ExtensionAction];
export interface ExtensionResponse<T = unknown> {
ok: boolean;
data?: T;
error?: string;
errorCode?: string;
}
export interface BridgeEnvelope {
id?: string;
type: 'challenge' | 'auth' | 'hello_ack' | 'request' | 'response' | 'event' | 'cancel' | 'ping' | 'pong' | 'chunk';
method?: string;
params?: unknown;
result?: unknown;
error?: { code: string; message: string };
client?: string;
version?: string;
protocolVersion?: number;
capabilities?: string[];
sessionId?: string;
taskId?: string;
grantId?: string;
installationId?: string;
engineInstanceId?: string;
engineIdentityId?: string;
challenge?: string;
signature?: string;
publicKey?: BridgePublicKey;
connectionId?: string;
resumeSessionId?: string;
resumed?: boolean;
sequence?: number;
timestamp?: number;
replyTimestamp?: number;
transferId?: string;
index?: number;
total?: number;
data?: string;
originalBytes?: number;
}
+742
View File
@@ -0,0 +1,742 @@
export type ProxyKind = 'direct' | 'system' | 'fixed_servers' | 'pac_script';
export type ProxyScheme = 'http' | 'https' | 'socks4' | 'socks5';
export interface ProxyProfile {
id: string;
name: string;
kind: ProxyKind;
host?: string;
port?: number;
scheme?: ProxyScheme;
pacUrl?: string;
pacScript?: string;
bypass: string[];
builtin?: boolean;
authEnabled?: boolean;
authUsername?: string;
}
export interface ProxyRule {
id: string;
name: string;
enabled: boolean;
patterns: string[];
proxyProfileId: string;
priority: number;
}
export interface ProxyRoutingSettings {
defaultProfileId: string;
failMode: 'open' | 'closed';
}
export interface ProxyRuleStats {
ruleId: string;
hits: number;
lastHitAt?: number;
lastUrl?: string;
}
export interface ProxyRulePreview {
url: string;
matchedRuleIds: string[];
effectiveRuleId?: string;
effectiveProfileId: string;
effectiveProxy: string;
conflict: boolean;
conflictProfileIds: string[];
}
export interface ProxyConfiguration {
version: 1;
profiles: ProxyProfile[];
rules: ProxyRule[];
routing: ProxyRoutingSettings;
}
export interface UserAgentRule {
id: string;
name: string;
enabled: boolean;
userAgent: string;
domains: string[];
}
export interface BridgeConfig {
transport: 'native' | 'websocket';
nativeHost: string;
endpoint: string;
autoConnect: boolean;
installationId: string;
pairedEngine?: BridgePairedEngine;
}
export interface BridgePublicKey {
kty: 'EC';
crv: 'P-256';
x: string;
y: string;
}
export interface BridgePairedEngine {
engineIdentityId: string;
deviceId: string;
publicKey: BridgePublicKey;
pairedAt: number;
}
export type BridgePairingState = 'idle' | 'requesting' | 'pending' | 'approved' | 'rejected' | 'expired' | 'error';
export interface BridgePairingStatus {
state: BridgePairingState;
message: string;
requestId?: string;
code?: string;
engineIdentityId?: string;
expiresAt?: number;
}
export type CapabilityScope =
| 'browser.tabs.read'
| 'browser.dom.read'
| 'browser.dom.write'
| 'browser.storage.read'
| 'browser.cookies.read'
| 'browser.tab.activate'
| 'browser.page.invoke'
| 'browser.page.eval.expression'
| 'browser.page.eval.program'
| 'browser.human.takeover'
| 'browser.network.read'
| 'browser.network.capture'
| 'browser.network.sensitive.read'
| 'browser.observation.read'
| 'browser.observation.control'
| 'browser.observation.sensitive.read'
| 'browser.proxy.read'
| 'browser.proxy.write';
export interface BrowserTarget {
tabId: number;
frameId: number;
documentId?: string;
}
export interface PageFrameSummary extends BrowserTarget {
parentFrameId: number;
parentDocumentId?: string;
url: string;
origin: string;
title: string;
name: string;
frameType: string;
documentLifecycle: string;
isTop: boolean;
sameOrigin: boolean;
accessible: boolean;
sandbox: string[];
}
export interface BridgeGrantTarget extends BrowserTarget {
origin: string;
grantedUrl: string;
title: string;
}
export interface BridgeGrant {
id: string;
taskId: string;
targets: BridgeGrantTarget[];
scopes: CapabilityScope[];
createdAt: number;
expiresAt: number;
}
export type HandoffReason = 'qr_code' | 'mfa' | 'captcha' | 'device_confirmation' | 'other';
export type HandoffState = 'waiting_for_user' | 'completed' | 'cancelled';
export interface HumanHandoff {
id: string;
taskId: string;
target: BridgeGrantTarget;
reason: HandoffReason;
message: string;
state: HandoffState;
requestedAt: number;
resolvedAt?: number;
}
export interface AuditEvent {
id: string;
timestamp: number;
category: 'grant' | 'bridge' | 'capability' | 'handoff' | 'settings';
action: string;
outcome: 'success' | 'denied' | 'error' | 'cancelled';
taskId?: string;
targetTabId?: number;
durationMs?: number;
errorCode?: string;
summary?: string;
}
export type AgentRuntimeState = 'idle' | 'running' | 'paused' | 'waiting_for_human' | 'revoked' | 'expired';
export type AgentActionState = 'running' | 'success' | 'denied' | 'error' | 'cancelled';
export interface AgentActionRecord {
id: string;
requestId: string;
taskId: string;
grantId: string;
method: string;
targetTabId?: number;
state: AgentActionState;
startedAt: number;
completedAt?: number;
durationMs?: number;
errorCode?: string;
}
export interface AgentRuntime {
state: AgentRuntimeState;
taskId?: string;
grantId?: string;
startedAt?: number;
pausedAt?: number;
updatedAt: number;
actions: AgentActionRecord[];
}
export interface NetworkCaptureOptions {
captureHeaders: boolean;
captureBody: boolean;
maxEntries: number;
maxBodyBytes: number;
}
export interface NetworkBody {
encoding: 'utf8' | 'base64';
data: string;
byteLength: number;
truncated: boolean;
reconstructed?: boolean;
}
export interface NetworkHeader {
name: string;
value: string;
}
export interface NetworkRedirect {
url: string;
statusCode: number;
redirectUrl: string;
timestamp: number;
}
export interface NetworkRequestRecord {
id: string;
requestId: string;
tabId: number;
frameId: number;
documentId?: string;
url: string;
method: string;
resourceType: string;
initiator?: string;
startedAt: number;
completedAt?: number;
durationMs?: number;
statusCode?: number;
statusLine?: string;
fromCache?: boolean;
ip?: string;
error?: string;
requestHeadersCaptured: boolean;
requestBodyCaptured: boolean;
requestHeaders?: NetworkHeader[];
responseHeaders?: NetworkHeader[];
requestBody?: NetworkBody;
responseContentType?: string;
responseSize?: number;
redirects: NetworkRedirect[];
}
export interface NetworkCaptureStatus {
active: boolean;
target: BrowserTarget;
startedAt?: number;
count: number;
droppedCount: number;
options?: NetworkCaptureOptions;
}
export interface NetworkRequestExport {
id: string;
url: string;
isHttps: boolean;
rawRequest: string;
rawRequestBase64: string;
limitations: string[];
}
export type PageObservationKind = 'fetch' | 'xhr' | 'form' | 'websocket' | 'webcrypto' | 'cryptojs';
export interface PageObservationOptions {
captureValues: boolean;
maxEntries: number;
maxValueBytes: number;
expiresAt?: number;
}
export interface PageObservationRecord {
id: string;
sequence: number;
timestamp: number;
kind: PageObservationKind;
operation: string;
url?: string;
method?: string;
algorithm?: string;
direction?: 'send' | 'receive';
socketId?: string;
byteLength?: number;
resultByteLength?: number;
dataType?: string;
stack?: string;
scriptUrl?: string;
sensitiveCaptured: boolean;
inputPreview?: string;
outputPreview?: string;
error?: string;
}
export interface PageObservationStatus {
active: boolean;
target: BrowserTarget;
startedAt?: number;
count: number;
droppedCount: number;
options?: PageObservationOptions;
}
export interface YakitFuzzerOpenResult {
pageId: string;
tabName: string;
}
export interface YakPocGenerateResult {
language: 'yak';
fileName: string;
code: string;
}
export interface BrowserRequestAnalysisSignal {
location: 'header' | 'parameter';
name: string;
category: 'authorization' | 'csrf' | 'signature' | 'nonce' | 'timestamp' | 'cookie';
}
export interface BrowserRequestAnalysisBundle {
request: {
method: string;
scheme: 'http' | 'https';
host: string;
path: string;
contentType: string;
queryKeys: string[];
headerNames: string[];
cookieNames: string[];
bodyKeys: string[];
bodyBytes: number;
};
signals: BrowserRequestAnalysisSignal[];
observations: Array<Pick<PageObservationRecord, 'kind' | 'operation' | 'algorithm' | 'direction' | 'scriptUrl' | 'byteLength' | 'resultByteLength' | 'timestamp'>>;
valuePolicy: string;
recommendedChecks: string[];
}
export interface FloatingPanelPreferences {
enabled: boolean;
side: 'left' | 'right';
y: number;
displayMode: 'always' | 'active-task';
siteMode: 'all' | 'allowlist' | 'denylist';
siteOrigins: string[];
shortcutEnabled: boolean;
autoCollapseFullscreen: boolean;
}
export type BridgeConnectionState = 'disconnected' | 'connecting' | 'negotiating' | 'connected' | 'error';
export interface BridgeStatus {
state: BridgeConnectionState;
message: string;
connectedAt?: number;
engineVersion?: string;
protocolVersion?: number;
capabilities?: string[];
sessionId?: string;
engineInstanceId?: string;
engineIdentityId?: string;
connectionId?: string;
taskId?: string;
grantId?: string;
resumed?: boolean;
heartbeatSequence?: number;
latencyMs?: number;
lastHeartbeatAt?: number;
}
export interface BridgeRuntimeSession {
sessionId: string;
engineInstanceId: string;
engineIdentityId?: string;
taskId?: string;
grantId?: string;
updatedAt: number;
}
export interface EnterprisePolicy {
bridgeTransport?: 'native' | 'websocket';
bridgeEndpoint?: string;
nativeHost?: string;
autoConnect?: boolean;
disableWebSocket?: boolean;
floatingPanelEnabled?: boolean;
maxGrantMinutes?: number;
grantAllowedOrigins?: string[];
allowProgramEval?: boolean;
}
export interface EnterprisePolicyStatus {
managed: boolean;
policy: EnterprisePolicy;
warnings: string[];
}
export interface RuntimeMetricAggregate {
count: number;
errorCount: number;
totalDurationMs: number;
maxDurationMs: number;
}
export interface RuntimeMetrics {
version: 1;
firstSeenAt: number;
updatedAt: number;
serviceWorkerStarts: number;
bridgeConnectAttempts: number;
bridgeConnections: number;
bridgeDisconnects: number;
bridgeErrors: number;
heartbeatSamples: number;
heartbeatLatencyTotalMs: number;
heartbeatLatencyMaxMs: number;
capabilities: Record<string, RuntimeMetricAggregate>;
}
export interface DiagnosticsBundle {
schemaVersion: 1;
generatedAt: number;
extension: { version: string; manifestVersion: number; buildChannel: string; permissions: string[] };
platform: { os: string; arch: string };
bridge: Omit<BridgeStatus, 'taskId' | 'grantId'>;
policy: EnterprisePolicyStatus;
state: {
proxyProfiles: number;
proxyRules: number;
userAgentRules: number;
floatingPanelEnabled: boolean;
activeGrant: boolean;
activeGrantTargets: number;
activeGrantScopes: CapabilityScope[];
handoffState?: HandoffState;
};
storageDomains: Record<string, boolean>;
metrics: RuntimeMetrics;
recentAudit: Array<Pick<AuditEvent, 'timestamp' | 'category' | 'action' | 'outcome' | 'durationMs' | 'errorCode'>>;
}
export interface ExtensionState {
version: 7;
proxyProfiles: ProxyProfile[];
proxyRules: ProxyRule[];
proxyRouting: ProxyRoutingSettings;
activeProxyId: string;
userAgentRules: UserAgentRule[];
bridge: BridgeConfig;
floatingPanel: FloatingPanelPreferences;
activeGrant?: BridgeGrant;
handoff?: HumanHandoff;
}
export interface ActiveTabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
lastAccessed?: number;
}
export interface PageContextOptions {
includeStorage?: boolean;
includeCookies?: boolean;
includeDom?: boolean;
}
export interface PageNodeReference {
captureId: string;
nodeId: string;
tabId: number;
frameId: number;
documentId?: string;
}
export interface PageNodeSummary {
nodeId: string;
semanticKey: string;
tag: string;
role: string;
type: string;
name: string;
text: string;
accessibleName: string;
selectorHint: string;
visible: boolean;
disabled: boolean;
required: boolean;
checked?: boolean;
href?: string;
placeholder?: string;
autocomplete?: string;
shadowDepth: number;
}
export interface PageFormSummary {
nodeId: string;
semanticKey: string;
action: string;
method: string;
name: string;
fieldNodeIds: string[];
}
export interface PageStorageEntry {
key: string;
value: string;
byteLength: number;
authRelated: boolean;
truncated: boolean;
}
export interface PageStorageSummary {
supported: boolean;
entries: PageStorageEntry[];
totalEntries: number;
approximateBytes: number;
truncated: boolean;
error?: string;
}
export interface IndexedDbStoreSummary {
name: string;
keyPath: string | string[] | null;
autoIncrement: boolean;
count?: number;
sampleKeys: Array<string | number>;
truncated: boolean;
error?: string;
}
export interface IndexedDbDatabaseSummary {
name: string;
version: number;
stores: IndexedDbStoreSummary[];
truncated: boolean;
error?: string;
}
export interface BrowserStorageInventory {
indexedDB: {
supported: boolean;
databases: IndexedDbDatabaseSummary[];
truncated: boolean;
error?: string;
};
cacheStorage: {
supported: boolean;
names: string[];
truncated: boolean;
error?: string;
};
}
export interface PageLifecycleEvent {
id: string;
kind: 'document' | 'history' | 'fragment';
tabId: number;
frameId: number;
documentId?: string;
url: string;
timestamp: number;
transitionType?: string;
}
export type PageAuthenticationStatus = 'authenticated' | 'unauthenticated' | 'unknown';
export interface PageAuthenticationSignals {
status: PageAuthenticationStatus;
confidence: number;
evidence: string[];
passwordFieldCount: number;
cookieNames: string[];
storageKeys: string[];
}
export interface PageContextChange {
semanticKey: string;
tag: string;
text: string;
nodeId?: string;
}
export interface PageContextDiff {
kind: 'initial' | 'unchanged' | 'changed' | 'document_changed';
fromCaptureId?: string;
toCaptureId: string;
changedSections: Array<'capture_options' | 'document' | 'authentication' | 'forms' | 'interactive' | 'storage' | 'cookies'>;
addedNodes: PageContextChange[];
removedNodes: PageContextChange[];
addedStorageKeys: string[];
removedStorageKeys: string[];
addedCookieNames: string[];
removedCookieNames: string[];
}
export type PageNodeAction = 'click' | 'focus' | 'scroll' | 'setValue';
export interface PageNodeDetails extends PageNodeSummary {
reference: PageNodeReference;
connected: boolean;
attributes: Record<string, string>;
bounds?: { x: number; y: number; width: number; height: number };
}
export interface PageNodeActionResult {
action: PageNodeAction;
completedAt: number;
node: PageNodeDetails;
}
export interface PageEvalRequest {
mode: 'expression' | 'program';
code: string;
tabId?: number;
frameId?: number;
documentId?: string;
timeoutMs?: number;
}
export interface GrantCreateInput {
targets: Array<{ tabId: number; frameId: number }>;
scopes: CapabilityScope[];
durationMinutes: number;
taskId?: string;
}
export interface PageEvalResult {
type: string;
value: unknown;
preview: string;
truncated: boolean;
durationMs: number;
}
export interface PageContext {
captureId: string;
capturedAt: number;
included: { dom: boolean; storage: boolean; cookies: boolean };
tab: ActiveTabInfo;
target: BrowserTarget;
frames: PageFrameSummary[];
lifecycle: PageLifecycleEvent[];
authentication: PageAuthenticationSignals;
diff: PageContextDiff;
document: {
title: string;
url: string;
referrer: string;
language: string;
charset: string;
readyState: string;
bodyText: string;
bodyTextTruncated: boolean;
headings: Array<{ level: number; text: string }>;
forms: PageFormSummary[];
interactive: PageNodeSummary[];
meta: Record<string, string>;
localStorage?: PageStorageSummary;
sessionStorage?: PageStorageSummary;
storageInventory?: BrowserStorageInventory;
cryptoCandidates: Array<{ path: string; kind: string }>;
scannedElementCount: number;
limitsReached: string[];
};
cookies?: BrowserCookie[];
}
export interface BrowserCookie {
name: string;
value: string;
domain: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: string;
session: boolean;
expirationDate?: number;
hostOnly: boolean;
storeId: string;
firstPartyDomain?: string;
partitionKey?: CookiePartitionKey;
priority?: 'low' | 'medium' | 'high';
sameParty?: boolean;
}
export interface CookiePartitionKey {
topLevelSite?: string;
hasCrossSiteAncestor?: boolean;
}
export interface CookieInput {
url: string;
name: string;
value: string;
domain?: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'no_restriction' | 'lax' | 'strict' | 'unspecified';
expirationDate?: number;
storeId?: string;
firstPartyDomain?: string;
partitionKey?: CookiePartitionKey;
}
export interface CookieRemoveInput {
url: string;
name: string;
storeId?: string;
firstPartyDomain?: string;
partitionKey?: CookiePartitionKey;
}
export type CookieTransferFormat = 'json' | 'netscape' | 'set-cookie';
export interface CookieImportResult {
imported: number;
failed: number;
warnings: string[];
}
-47
View File
@@ -1,47 +0,0 @@
export interface PacScript {
data?: string;
url?: string;
mandatory?: boolean;
}
export interface ProxyConfig {
id: string;
name: string;
enabled: boolean;
proxyType: "direct" | "system" | "fixed_servers" | "pac_script" | "auto_detect";
mode?: string;
host?: string;
port?: number;
scheme?: "http" | "https" | "socks4" | "socks5";
pacScript?: PacScript;
bypassList?: string[];
matchList?: string[];
username?: string;
password?: string;
}
export interface ProxyLog {
id: string;
timestamp: number;
url: string;
proxyId: string;
proxyName: string;
status: 'success' | 'error';
errorMessage?: string;
method?: string;
requestHeaders?: Record<string, string>;
requestBody?: string;
responseHeaders?: Record<string, string>;
responseBody?: string;
timing?: {
startTime: number;
endTime: number;
duration: number;
};
protocol?: string;
ip?: string;
fromCache?: boolean;
host?: string;
port?: number;
resourceType?: 'xhr' | 'fetch' | 'script' | 'stylesheet' | 'image' | 'other';
}
-191
View File
@@ -1,191 +0,0 @@
import { browser } from 'wxt/browser';
import type { ProxyConfig } from '../types/proxy';
import { getAllProxyConfigs, getProxyConfig, enableProxyConfig, disableAllProxies, getCurrentProxy, setCurrentProxy } from './storage';
/**
* 获取当前激活的代理模式
* @returns 返回当前的代理模式(direct, system, 或代理ID)
*/
export async function getCurrentProxyMode(): Promise<string> {
try {
// 首先尝试从当前代理存储中获取
const currentProxyConfig = await getCurrentProxy();
if (currentProxyConfig) {
return currentProxyConfig.id;
}
// 如果没有当前代理记录,则从配置列表查找已启用的代理
const configs = await getAllProxyConfigs();
const enabledProxy = configs.find(config => config.enabled);
if (enabledProxy) {
// 如果找到已启用的代理,更新当前代理存储
await setCurrentProxy(enabledProxy);
return enabledProxy.id;
}
// 如果没有启用的代理,返回直接连接模式
return 'direct';
} catch (error) {
console.error('Error getting current proxy mode:', error);
return 'direct';
}
}
/**
* 检查浏览器是否支持代理API
* @returns 是否支持代理API
*/
function hasProxySupport(): boolean {
return browser.proxy !== undefined && browser.proxy.settings !== undefined;
}
/**
* 检测当前浏览器是否为 Firefox
* @returns 是否为 Firefox 浏览器
*/
function isFirefox(): boolean {
// 使用 WXT 提供的环境变量检测浏览器
if (typeof import.meta.env !== 'undefined') {
// 首选方式:使用 WXT 的内置环境变量
if (import.meta.env.FIREFOX !== undefined) {
return Boolean(import.meta.env.FIREFOX);
}
if (import.meta.env.BROWSER === 'firefox') {
return true;
}
}
return false;
}
/**
* 修复Firefox和Chrome的代理配置差异
* @param config 代理配置
* @returns 浏览器特定的代理配置
*/
function createBrowserProxyConfig(config: ProxyConfig): any {
// 不同浏览器的代理配置格式
const firefoxBrowser = isFirefox();
if (config.proxyType === 'direct') {
return null; // 直接连接模式返回null,由clear方法处理
} else if (config.proxyType === 'system') {
// 系统代理模式对两种浏览器都一样
return { mode: 'system' };
} else if (config.proxyType === 'fixed_servers') {
if (firefoxBrowser) {
// Firefox格式
const proxyConfig: any = {
proxyType: 'manual'
};
if (config.scheme === 'http' || config.scheme === 'https') {
proxyConfig.http = `${config.scheme}://${config.host}:${config.port}`;
proxyConfig.ssl = `${config.scheme}://${config.host}:${config.port}`;
proxyConfig.httpProxyAll = true;
} else if (config.scheme === 'socks4' || config.scheme === 'socks5') {
proxyConfig.socks = `${config.host}:${config.port}`;
proxyConfig.socksVersion = config.scheme === 'socks4' ? 4 : 5;
proxyConfig.proxyDNS = true;
}
// 设置绕过代理的列表
if (config.bypassList && config.bypassList.length > 0) {
proxyConfig.passthrough = config.bypassList.join(', ');
}
return proxyConfig;
} else {
// Chrome格式
const proxyConfig: any = {
mode: 'fixed_servers',
rules: {}
};
// 添加代理规则
const proxyRule = {
scheme: config.scheme,
host: config.host || '',
port: config.port || 80
};
// 设置代理规则
proxyConfig.rules = {
singleProxy: proxyRule,
bypassList: config.bypassList || ['localhost', '127.0.0.1']
};
return proxyConfig;
}
} else if (config.proxyType === 'pac_script') {
if (firefoxBrowser) {
// Firefox格式
return {
proxyType: 'autoConfig',
autoConfigUrl: config.pacScript?.url,
autoLogin: true
};
} else {
// Chrome格式
return {
mode: 'pac_script',
pacScript: config.pacScript
};
}
}
return null;
}
/**
* 切换到指定的代理模式
* @param mode 代理模式(ID, direct, 或 system)
* @returns 是否成功切换
*/
export async function switchProxyMode(mode: string): Promise<boolean> {
try {
// 检查代理API是否可用
if (!hasProxySupport()) {
console.error('Proxy API not supported in this browser');
return false;
}
// 获取对应的代理配置
const config = await getProxyConfig(mode);
if (!config) {
console.error(`Proxy config with ID ${mode} not found`);
return false;
}
// 启用选定的代理配置
await enableProxyConfig(config.id);
// 设置代理
if (config.proxyType === 'direct') {
// 直接连接模式
await browser.proxy.settings.clear({});
} else {
// 获取浏览器特定的代理配置
const proxyConfig = createBrowserProxyConfig(config);
if (proxyConfig !== null) {
// 应用代理设置
await browser.proxy.settings.set({
value: proxyConfig,
scope: 'regular'
});
}
}
return true;
} catch (error) {
console.error(`Error switching to proxy mode ${mode}:`, error);
// 更详细的错误信息
if (typeof error === 'object' && error !== null) {
console.error('Error details:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
}
return false;
}
}
-296
View File
@@ -1,296 +0,0 @@
import type { ProxyConfig } from '../types/proxy';
// 数据库名称和存储名称
const DB_NAME = 'yaklang_extension';
const STORES = {
PROXY_CONFIGS: 'proxy_configs', // 代理配置列表存储
CURRENT_PROXY: 'current_proxy', // 当前代理配置存储
PROXY_AUTH: 'proxy_auth' // 代理认证信息存储
};
const DB_VERSION = 2; // 增加版本号以触发数据库升级
// 打开数据库
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 检查并创建各个存储对象
if (!db.objectStoreNames.contains(STORES.PROXY_CONFIGS)) {
db.createObjectStore(STORES.PROXY_CONFIGS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORES.CURRENT_PROXY)) {
db.createObjectStore(STORES.CURRENT_PROXY, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORES.PROXY_AUTH)) {
db.createObjectStore(STORES.PROXY_AUTH, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
});
}
// 获取所有代理配置
export async function getAllProxyConfigs(): Promise<ProxyConfig[]> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readonly');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
} catch (error) {
console.error('Error getting proxy configs:', error);
return [];
}
}
// 获取单个代理配置
export async function getProxyConfig(id: string): Promise<ProxyConfig | null> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readonly');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
} catch (error) {
console.error(`Error getting proxy config ${id}:`, error);
return null;
}
}
// 保存代理配置
export async function saveProxyConfig(config: ProxyConfig): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readwrite');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
const request = store.put(config);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
// 如果代理被启用,更新当前代理
if (config.enabled) {
setCurrentProxy(config).catch(console.error);
}
resolve(true);
};
});
} catch (error) {
console.error('Error saving proxy config:', error);
return false;
}
}
// 批量保存代理配置
export async function saveProxyConfigs(configs: ProxyConfig[]): Promise<boolean> {
try {
const db = await openDB();
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readwrite');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
// 获取所有现有配置
const existingConfigs = await getAllProxyConfigs();
// 保留固定模式配置(direct和system)
const fixedModeConfigs = existingConfigs.filter(config =>
config.id === 'direct' || config.id === 'system'
);
// 确保新保存的配置不会覆盖固定模式的enabled状态
const nonFixedConfigs = configs.filter(config =>
config.id !== 'direct' && config.id !== 'system'
);
// 合并配置
const allConfigs = [...fixedModeConfigs, ...nonFixedConfigs];
// 更新enabled状态
const updatedConfigs = allConfigs.map(config => ({
...config,
enabled: configs.some(c => c.id === config.id && c.enabled)
}));
// 清除所有配置
await new Promise<void>((resolve, reject) => {
const clearRequest = store.clear();
clearRequest.onerror = () => reject(clearRequest.error);
clearRequest.onsuccess = () => resolve();
});
// 保存所有配置
for (const config of updatedConfigs) {
await new Promise<void>((resolve, reject) => {
const putRequest = store.put(config);
putRequest.onerror = () => reject(putRequest.error);
putRequest.onsuccess = () => resolve();
});
// 如果代理被启用,更新当前代理
if (config.enabled) {
await setCurrentProxy(config);
}
}
return true;
} catch (error) {
console.error('Error saving proxy configs:', error);
return false;
}
}
// 删除代理配置
export async function deleteProxyConfig(id: string): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readwrite');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(true);
});
} catch (error) {
console.error(`Error deleting proxy config ${id}:`, error);
return false;
}
}
// 设置当前代理
export async function setCurrentProxy(proxy: ProxyConfig): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.CURRENT_PROXY], 'readwrite');
const store = transaction.objectStore(STORES.CURRENT_PROXY);
const request = store.put({ ...proxy, id: 'current' });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(true);
});
} catch (error) {
console.error('Error setting current proxy:', error);
return false;
}
}
// 获取当前代理
export async function getCurrentProxy(): Promise<ProxyConfig | null> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.CURRENT_PROXY], 'readonly');
const store = transaction.objectStore(STORES.CURRENT_PROXY);
const request = store.get('current');
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
} catch (error) {
console.error('Error getting current proxy:', error);
return null;
}
}
// 清除当前代理
export async function clearCurrentProxy(): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORES.CURRENT_PROXY], 'readwrite');
const store = transaction.objectStore(STORES.CURRENT_PROXY);
const request = store.delete('current');
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(true);
});
} catch (error) {
console.error('Error clearing current proxy:', error);
return false;
}
}
// 启用指定的代理,禁用其他
export async function enableProxyConfig(id: string): Promise<boolean> {
try {
// 获取所有配置
const configs = await getAllProxyConfigs();
// 查找目标代理
const targetProxy = configs.find(config => config.id === id);
if (!targetProxy) {
console.error(`找不到ID为 ${id} 的代理配置`);
return false;
}
// 创建更新后的代理配置
const updatedConfigs = configs.map(config => ({
...config,
enabled: config.id === id
}));
// 保存到数据库
const db = await openDB();
const transaction = db.transaction([STORES.PROXY_CONFIGS], 'readwrite');
const store = transaction.objectStore(STORES.PROXY_CONFIGS);
// 逐个更新配置
for (const config of updatedConfigs) {
await new Promise<void>((resolve, reject) => {
const request = store.put(config);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
// 设置当前代理
await setCurrentProxy({
...targetProxy,
enabled: true
});
return true;
} catch (error) {
console.error(`Error enabling proxy config ${id}:`, error);
return false;
}
}
// 禁用所有代理
export async function disableAllProxies(): Promise<boolean> {
try {
const configs = await getAllProxyConfigs();
const updatedConfigs = configs.map(config => ({
...config,
enabled: false
}));
// 保存更新后的配置列表
await saveProxyConfigs(updatedConfigs);
// 清除当前代理
await clearCurrentProxy();
return true;
} catch (error) {
console.error('Error disabling all proxies:', error);
return false;
}
}