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