feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+114 -42
View File
@@ -1,7 +1,17 @@
import { browser } from 'wxt/browser';
import { installPageWorldBridge } from '@/features/page-context/content-bridge';
import { installPageRecorderBridge } from '@/features/browser-recording/content-bridge';
import { isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, BridgeStatus, ExtensionState } from '@/types/models';
import {
createLazyUnloadController,
floatingPanelVisible,
isFloatingPanelShortcut,
mergeFloatingTabUpdate,
resolvePanelPlacement,
shouldCollapseForFullscreen,
} from '@/features/floating-panel/host-controller';
import { createOpaqueId } from '@/shared/id';
const PANEL_IDLE_UNLOAD_MS = 60_000;
@@ -11,15 +21,20 @@ const shellCss = `
.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__header { position: absolute; top: 0; z-index: 2; width: 46px; height: 46px; display: flex; align-items: center; overflow: hidden; border: 1px solid #d7dce1; background: #fff; color: #1d232b; box-sizing: border-box; touch-action: none; user-select: none; transition: width .16s ease; }
.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.is-expanded .floating-panel__header { width: 100%; border-radius: 8px 8px 0 0; box-shadow: 0 7px 20px rgba(20,24,28,.14); }
.floating-panel--right.is-expanded .floating-panel__header { flex-direction: row-reverse; }
.floating-panel__brand { position: relative; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; 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__header { border-color: #343a40; background: #1d232b; color: #f1f3f5; }
:host([data-theme='dark']) .floating-panel__brand { 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--left:not(.is-expanded) .floating-panel__header { border-left: 0; border-radius: 0 23px 23px 0; }
.floating-panel--right:not(.is-expanded) .floating-panel__header { border-right: 0; border-radius: 23px 0 0 23px; }
.floating-panel--left:not(.is-expanded) .floating-panel__brand { border-radius: 0 23px 23px 0; }
.floating-panel--right:not(.is-expanded) .floating-panel__brand { 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; }
@@ -28,7 +43,16 @@ const shellCss = `
.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); }
.floating-panel__title { min-width: 0; flex: 1; padding: 0 9px; display: none; }
.floating-panel.is-expanded .floating-panel__title { display: grid; gap: 1px; }
.floating-panel__title strong, .floating-panel__title span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: system-ui, sans-serif; }
.floating-panel__title strong { font-size: 12px; line-height: 16px; font-weight: 650; }
.floating-panel__title span { color: #697078; font-size: 10px; line-height: 14px; }
:host([data-theme='dark']) .floating-panel__title span { color: #a7afb8; }
.floating-panel__grip { width: 20px; flex: 0 0 20px; display: none; color: #90979e; font: 14px/1 system-ui, sans-serif; letter-spacing: -2px; }
.floating-panel.is-expanded .floating-panel__grip { display: block; }
iframe { width: 100%; height: 320px; margin-top: 46px; display: block; border: 0; border-radius: 0 0 8px 8px; box-shadow: 0 10px 28px rgba(22,28,33,.18); }
.floating-panel:not(.is-expanded) iframe { visibility: hidden; pointer-events: none; }
`;
async function send<T>(action: string, payload?: unknown): Promise<T> {
@@ -42,6 +66,11 @@ export default defineContentScript({
runAt: 'document_start',
async main(ctx) {
if (import.meta.env.FIREFOX) {
await installPageRecorderBridge(ctx).catch((error) => {
console.warn('[Yakit Browser Agent] Firefox page recorder bridge is unavailable.', error);
});
}
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) => {
@@ -68,7 +97,16 @@ export default defineContentScript({
const signal = document.createElement('span');
signal.className = 'floating-panel__signal disconnected';
launcher.append(logo, signal);
header.append(launcher);
const headerTitle = document.createElement('span');
headerTitle.className = 'floating-panel__title';
const headerPageTitle = document.createElement('strong');
const headerPageUrl = document.createElement('span');
headerTitle.append(headerPageTitle, headerPageUrl);
const grip = document.createElement('span');
grip.className = 'floating-panel__grip';
grip.textContent = '⠿';
grip.setAttribute('aria-hidden', 'true');
header.append(launcher, headerTitle, grip);
panel.append(header);
shadow.append(style, panel);
document.documentElement.append(host);
@@ -88,17 +126,32 @@ export default defineContentScript({
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 frameChannel = createOpaqueId('floating-channel');
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 updateHeaderPage = () => {
headerPageTitle.textContent = currentTab?.title || document.title || '当前页面';
headerPageTitle.title = headerPageTitle.textContent;
headerPageUrl.textContent = currentTab?.url || location.href;
headerPageUrl.title = headerPageUrl.textContent;
};
const postTabToFrame = () => {
if (!frame?.contentWindow || !currentTab) return;
frame.contentWindow.postMessage({
channel: 'yakit-floating-host', token: frameChannel, type: 'tab.changed',
tab: { tabId: currentTab.id, title: currentTab.title, url: currentTab.url },
}, '*');
};
const applyTabUpdate = (update: { tabId: number; title?: string; url?: string }) => {
const next = mergeFloatingTabUpdate(currentTab, update);
if (next === currentTab) return;
currentTab = next;
updateHeaderPage();
postTabToFrame();
if (state) applyState(state);
};
const adjustForEdgeConflict = () => {
if (host.style.display === 'none') return;
@@ -118,15 +171,7 @@ export default defineContentScript({
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);
const visible = floatingPanelVisible(next, currentTab, location.origin);
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');
@@ -142,22 +187,23 @@ export default defineContentScript({
if (frame) return;
frame = document.createElement('iframe');
frame.title = 'Yakit Browser Agent';
frame.src = `${browser.runtime.getURL('/floating.html')}?tabId=${currentTab?.id || ''}`;
frame.src = `${browser.runtime.getURL('/floating.html')}?tabId=${currentTab?.id || ''}&channel=${encodeURIComponent(frameChannel)}`;
frame.addEventListener('load', postTabToFrame, { once: true });
panel.prepend(frame);
};
const unloadFrame = () => {
frame?.remove();
frame = undefined;
};
const lazyUnload = createLazyUnloadController(PANEL_IDLE_UNLOAD_MS, unloadFrame);
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);
lazyUnload.schedule();
}
const expand = () => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
lazyUnload.cancel();
ensureFrame();
expanded = true;
panel.classList.add('is-expanded');
@@ -170,73 +216,99 @@ export default defineContentScript({
send<BridgeStatus>('bridge.status'),
]);
currentTab = initialTab;
updateHeaderPage();
applyState(initialState);
setBridgeStatus(initialBridge);
launcher.addEventListener('pointerdown', (event) => {
header.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
launcher.setPointerCapture(event.pointerId);
header.setPointerCapture(event.pointerId);
});
launcher.addEventListener('pointermove', (event) => {
header.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);
const { side, y } = resolvePanelPlacement(event.clientX, event.clientY, innerWidth, innerHeight);
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) => {
header.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);
const { side, y } = resolvePanelPlacement(event.clientX, event.clientY, innerWidth, innerHeight);
void send<ExtensionState>('panel.update', { side, y }).then(applyState).catch(() => undefined);
} else if (expanded) collapse(); else expand();
});
header.addEventListener('pointercancel', () => { drag = undefined; });
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 input = message as { action?: string; payload?: unknown };
if (input?.action === 'bridge.status.changed' && input.payload) setBridgeStatus(input.payload as BridgeStatus);
if (input?.action === 'floating.tab.changed' && input.payload) {
applyTabUpdate(input.payload as { tabId: number; title?: string; url?: string });
}
};
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;
const data = event.data as { channel?: string; token?: string; type?: string; height?: number };
if (event.source !== frame?.contentWindow || data?.channel !== 'yakit-floating-host' || data.token !== frameChannel) 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 availableHeight = Math.max(160, Math.min(480, innerHeight - 62));
frame.style.height = `${Math.min(Math.max(Math.ceil(data.height), 160), availableHeight)}px`;
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (!state?.floatingPanel.shortcutEnabled || !event.altKey || !event.shiftKey || event.code !== 'KeyY') return;
if (!state) return;
const target = event.target as HTMLElement | null;
const editable = Boolean(target?.isContentEditable || target?.closest('input, textarea, select, [contenteditable="true"]'));
if (!isFloatingPanelShortcut(state.floatingPanel, event, editable)) return;
if (host.style.display === 'none') return;
event.preventDefault();
if (expanded) collapse(); else expand();
};
const onFullscreenChange = () => {
if (state?.floatingPanel.autoCollapseFullscreen && document.fullscreenElement) collapse();
if (state && shouldCollapseForFullscreen(state.floatingPanel, Boolean(document.fullscreenElement))) collapse();
};
const onResize = () => requestAnimationFrame(adjustForEdgeConflict);
const syncDocumentMetadata = () => {
if (!currentTab) return;
applyTabUpdate({ tabId: currentTab.id, title: document.title, url: location.href });
};
let titleObserver: MutationObserver | undefined;
const installTitleObserver = () => {
if (titleObserver || !document.head) return;
titleObserver = new MutationObserver(syncDocumentMetadata);
titleObserver.observe(document.head, { subtree: true, childList: true, characterData: true });
syncDocumentMetadata();
};
if (document.head) installTitleObserver();
else document.addEventListener('DOMContentLoaded', installTitleObserver, { once: true });
browser.storage.onChanged.addListener(onStorageChange);
browser.runtime.onMessage.addListener(onRuntimeMessage);
globalThis.addEventListener('message', onFrameMessage);
globalThis.addEventListener('keydown', onKeyDown, true);
globalThis.addEventListener('popstate', syncDocumentMetadata);
globalThis.addEventListener('hashchange', syncDocumentMetadata);
document.addEventListener('fullscreenchange', onFullscreenChange);
globalThis.addEventListener('resize', onResize);
ctx.onInvalidated(() => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
lazyUnload.dispose();
titleObserver?.disconnect();
document.removeEventListener('DOMContentLoaded', installTitleObserver);
browser.storage.onChanged.removeListener(onStorageChange);
browser.runtime.onMessage.removeListener(onRuntimeMessage);
globalThis.removeEventListener('message', onFrameMessage);
globalThis.removeEventListener('keydown', onKeyDown, true);
globalThis.removeEventListener('popstate', syncDocumentMetadata);
globalThis.removeEventListener('hashchange', syncDocumentMetadata);
document.removeEventListener('fullscreenchange', onFullscreenChange);
globalThis.removeEventListener('resize', onResize);
host.remove();
-29
View File
@@ -17,35 +17,6 @@ html, body { width: 100%; height: 100%; margin: 0; pointer-events: none; }
.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);
+2 -3
View File
@@ -1,6 +1,5 @@
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';
@@ -15,6 +14,7 @@ watchTheme();
function FloatingApp() {
const [initial, setInitial] = useState<{ state: ExtensionState; tab?: ActiveTabInfo; bridge: BridgeStatus }>();
const [error, setError] = useState('');
const hostChannel = new URLSearchParams(location.search).get('channel') || '';
useEffect(() => {
const tabId = Number(new URLSearchParams(location.search).get('tabId'));
@@ -35,8 +35,7 @@ function FloatingApp() {
initialState={initial.state}
initialTab={initial.tab}
initialBridge={initial.bridge}
yakIconUrl={browser.runtime.getURL('/yak.svg')}
embedded
hostChannel={hostChannel}
/>
);
}
+2 -4
View File
@@ -1,8 +1,6 @@
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-panel.floating-panel--embedded { position: relative; inset: auto; width: 100%; height: auto; transform: none; filter: none; }
.floating-panel--embedded .floating-panel__body { max-height: 100%; overflow: auto; box-shadow: none; }
.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); }
+5
View File
@@ -102,6 +102,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
.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; }
.topbar-workspace-context { min-width: 0; display: flex; align-items: center; gap: 9px; color: var(--muted-strong); }
.topbar-workspace-context > svg { color: var(--primary); }
.topbar-workspace-context strong, .topbar-workspace-context small { display: block; }
.topbar-workspace-context strong { color: var(--foreground); font-size: var(--text-sm); line-height: 16px; }
.topbar-workspace-context small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); line-height: 14px; }
.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; }
+69 -14
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react
import { browser, type Browser } from 'wxt/browser';
import {
Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, CloudDownload, Cookie, Copy,
Database, Download, Eye, History, KeyRound, MousePointer2, Network, Play, Power, Radio,
Database, Download, Eye, Fingerprint, History, KeyRound, MousePointer2, Network, Play, Power, Radio,
RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, Wrench, X,
} from 'lucide-react';
import { ProductBrand, YakitMark } from '@/components/brand/Brand';
@@ -18,6 +18,8 @@ import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView';
import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace';
import { gatewayShareActive, gatewayShareGrantInput } from '@/features/grants/gateway-share';
import { CAPABILITY_LABELS, CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
import type {
@@ -30,11 +32,17 @@ import { errorMessage, request } from '@/platform/messaging/runtime';
import { APPEARANCE_STORAGE_KEY, getAppearance, setThemePreference, type ThemePreference } from '@/platform/storage/appearance';
import './App.css';
type Section = 'overview' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity';
type Section = 'overview' | 'authorization' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity';
const FIREFOX_AMO_BUILD = import.meta.env.FIREFOX && import.meta.env.MODE === 'store';
const NAVIGATION: Array<{ label: string; icon?: ReactNode; items: Array<{ id: Section; label: string; icon: ReactNode }> }> = [
{ label: '工作区', items: [{ id: 'overview', label: '运行概览', icon: <CircleGauge size={17} /> }] },
{
label: '工作区',
items: [
{ id: 'overview', label: '运行概览', icon: <CircleGauge size={17} /> },
{ id: 'authorization', label: '授权测试', icon: <Fingerprint size={17} /> },
],
},
{
label: '网络与流量',
items: [
@@ -212,10 +220,13 @@ function App() {
<main className="workspace">
<header className="topbar">
<div className="topbar-tab">
{section === 'authorization' ? <div className="topbar-workspace-context">
<Fingerprint size={16} />
<div><strong>授权测试</strong><small>A/B 页面在工作区内选择</small></div>
</div> : <div className="topbar-tab">
<span className="topbar-tab__favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={13} />}</span>
<select className="target-tab-select" aria-label="目标标签页" value={tab?.id || ''} onChange={(event) => void selectTab(Number(event.target.value))}><option value="" disabled>选择目标标签页</option>{tabs.map((item) => <option value={item.id} key={item.id}>{item.title}</option>)}</select>
</div>
</div>}
<div className="topbar-actions"><span className={`permission-state ${state.activeGrant ? 'enabled' : ''}`}><ShieldCheck size={14} />{state.activeGrant ? `${isControlScopeSet(state.activeGrant.scopes) ? '控制' : '只读'}会话` : '未共享'}</span><Button size="icon" variant="ghost" title="刷新状态" onClick={() => void load()}><RefreshCw size={17} /></Button></div>
</header>
@@ -223,12 +234,13 @@ function App() {
<div className="content-area">
{section === 'overview' && <Overview state={state} bridge={bridge} tab={tab} navigate={navigate} run={run} busy={busy} />}
{section === 'authorization' && <AuthorizationTestingWorkspace state={state} setState={setState} tabs={tabs} activeTab={tab} bridge={bridge} refreshTabs={refreshTabs} run={run} busy={busy} />}
{section === 'proxies' && <ProxyProfilesView state={state} setState={setState} run={run} busy={busy} tab={tab} />}
{section === 'rules' && <AutoSwitchView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
{section === 'sources' && <RuleSourcesView state={state} setState={setState} tab={tab} run={run} busy={busy} />}
{section === 'cookies' && <CookieEditor key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
{section === 'user-agent' && <UserAgents state={state} setState={setState} tab={tab} run={run} busy={busy} />}
{section === 'network' && <NetworkActivity key={tab?.id || 0} tab={tab} bridge={bridge} run={run} busy={busy} />}
{section === 'network' && <NetworkActivity key={tab?.id || 0} state={state} setState={setState} tab={tab} bridge={bridge} run={run} busy={busy} />}
{section === 'context' && <ContextTool key={tab?.id || 0} tab={tab} run={run} busy={busy} />}
{section === 'engine' && <EngineSettings state={state} setState={setState} bridge={bridge} setBridge={setBridge} tabs={tabs} run={run} busy={busy} />}
{section === 'activity' && <ActivityLog run={run} busy={busy} />}
@@ -384,7 +396,8 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
});
const keyOf = cookieKey;
const reload = () => run(async () => {
setCookies(await request('cookie.list', { url }));
if (!tab?.id) throw new Error('请选择目标标签页');
setCookies(await request('cookie.list', { url, tabId: tab.id }));
setSelected(new Set());
});
const editCookie = (cookie: BrowserCookie) => {
@@ -414,7 +427,13 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
}
const removeInputs = (items: BrowserCookie[]) => items.map(cookieRemovalInput);
const downloadExport = async () => {
const text = await request('cookie.export', { url, format: transferFormat, includeValues: includeExportValues });
if (!tab?.id) throw new Error('请选择目标标签页');
const text = await request('cookie.export', {
url,
tabId: tab.id,
format: transferFormat,
includeValues: includeExportValues,
});
const blobUrl = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const anchor = document.createElement('a');
anchor.href = blobUrl;
@@ -426,12 +445,12 @@ function CookieEditor({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: ()
return <div className="section-view">
<div className="page-heading"><div><h1>Cookie Editor</h1><p>HttpOnly、Cookie Store、CHIPS 分区与多格式交换。</p></div><button disabled={busy || !url} onClick={() => void reload()}><RefreshCw size={16} />刷新</button></div>
<div className="url-bar"><input value={url} onChange={(event) => setUrl(event.target.value)} /><span>{cookies.length} cookies</span></div>
<div className="cookie-toolbar"><div className="network-search"><Search size={14} /><input aria-label="搜索 Cookie" placeholder="搜索名称、Domain 或 Path" value={query} onChange={(event) => setQuery(event.target.value)} /></div><select aria-label="Cookie 筛选" value={filter} onChange={(event) => setFilter(event.target.value as typeof filter)}><option value="all">全部</option><option value="session">Session</option><option value="persistent">持久</option><option value="httpOnly">HttpOnly</option><option value="partitioned">Partitioned</option></select><select aria-label="Cookie 排序" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="name">按名称</option><option value="domain">按 Domain</option><option value="expires">按过期时间</option><option value="size">按值大小</option></select><select aria-label="Cookie 分组" value={group} onChange={(event) => setGroup(event.target.value as typeof group)}><option value="domain">Domain 分组</option><option value="path">Path 分组</option><option value="none">不分组</option></select><Button variant="danger" disabled={busy || selected.size === 0} onClick={() => void run(async () => { const result = await request('cookie.removeMany', { cookies: removeInputs(cookies.filter((cookie) => selected.has(keyOf(cookie)))) }); setTransferStatus(`删除 ${result.removed},失败 ${result.failed}`); setCookies(await request('cookie.list', { url })); setSelected(new Set()); }, '已执行批量删除')}><Trash2 size={14} />删除 {selected.size || ''}</Button></div>
<div className="cookie-toolbar"><div className="network-search"><Search size={14} /><input aria-label="搜索 Cookie" placeholder="搜索名称、Domain 或 Path" value={query} onChange={(event) => setQuery(event.target.value)} /></div><select aria-label="Cookie 筛选" value={filter} onChange={(event) => setFilter(event.target.value as typeof filter)}><option value="all">全部</option><option value="session">Session</option><option value="persistent">持久</option><option value="httpOnly">HttpOnly</option><option value="partitioned">Partitioned</option></select><select aria-label="Cookie 排序" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="name">按名称</option><option value="domain">按 Domain</option><option value="expires">按过期时间</option><option value="size">按值大小</option></select><select aria-label="Cookie 分组" value={group} onChange={(event) => setGroup(event.target.value as typeof group)}><option value="domain">Domain 分组</option><option value="path">Path 分组</option><option value="none">不分组</option></select><Button variant="danger" disabled={busy || selected.size === 0} onClick={() => void run(async () => { if (!tab?.id) throw new Error('请选择目标标签页'); const result = await request('cookie.removeMany', { cookies: removeInputs(cookies.filter((cookie) => selected.has(keyOf(cookie)))) }); setTransferStatus(`删除 ${result.removed},失败 ${result.failed}`); setCookies(await request('cookie.list', { url, tabId: tab.id })); setSelected(new Set()); }, '已执行批量删除')}><Trash2 size={14} />删除 {selected.size || ''}</Button></div>
<div className="cookie-layout"><div className="cookie-table"><div className="table-head cookie-columns"><input aria-label="选择全部可见 Cookie" type="checkbox" checked={visibleCookies.length > 0 && visibleCookies.every((cookie) => selected.has(keyOf(cookie)))} onChange={(event) => setSelected(event.target.checked ? new Set(visibleCookies.map(keyOf)) : new Set())} /><span>名称</span><span>值</span><span>Domain / Path</span><span>属性</span><span /></div>{visibleCookies.length === 0 ? <Empty>没有符合条件的 Cookie。</Empty> : [...groupedCookies].map(([groupName, items]) => <div className="cookie-group" key={groupName}><div className="cookie-group__heading"><strong>{groupName}</strong><span>{items.length}</span></div>{items.map((cookie) => {
const cookieKey = keyOf(cookie);
return <div className="table-row cookie-columns" key={cookieKey}><input aria-label={`选择 ${cookie.name}`} type="checkbox" checked={selected.has(cookieKey)} onChange={(event) => setSelected((current) => { const next = new Set(current); if (event.target.checked) next.add(cookieKey); else next.delete(cookieKey); return next; })} /><button className="cookie-name-button" title="编辑 Cookie" onClick={() => editCookie(cookie)}><strong>{cookie.name}</strong></button><code className="cookie-value" title={cookie.value}>{cookie.value}</code><span><small>{cookie.domain}</small><small>{cookie.path}</small></span><span className="tag-list">{cookie.httpOnly && <i>HttpOnly</i>}{cookie.secure && <i>Secure</i>}{cookie.partitionKey && <i>Partitioned</i>}{cookie.sameSite && <i>{cookie.sameSite}</i>}{cookie.priority && <i>{cookie.priority}</i>}{cookie.sameParty && <i>SameParty</i>}</span><button className="icon-button danger" title="删除 Cookie" onClick={() => void run(async () => { await request('cookie.remove', removeInputs([cookie])[0]); setCookies(await request('cookie.list', { url })); }, 'Cookie 已删除')}><Trash2 size={15} /></button></div>;
return <div className="table-row cookie-columns" key={cookieKey}><input aria-label={`选择 ${cookie.name}`} type="checkbox" checked={selected.has(cookieKey)} onChange={(event) => setSelected((current) => { const next = new Set(current); if (event.target.checked) next.add(cookieKey); else next.delete(cookieKey); return next; })} /><button className="cookie-name-button" title="编辑 Cookie" onClick={() => editCookie(cookie)}><strong>{cookie.name}</strong></button><code className="cookie-value" title={cookie.value}>{cookie.value}</code><span><small>{cookie.domain}</small><small>{cookie.path}</small></span><span className="tag-list">{cookie.httpOnly && <i>HttpOnly</i>}{cookie.secure && <i>Secure</i>}{cookie.partitionKey && <i>Partitioned</i>}{cookie.sameSite && <i>{cookie.sameSite}</i>}{cookie.priority && <i>{cookie.priority}</i>}{cookie.sameParty && <i>SameParty</i>}</span><button className="icon-button danger" title="删除 Cookie" onClick={() => void run(async () => { if (!tab?.id) throw new Error('请选择目标标签页'); await request('cookie.remove', removeInputs([cookie])[0]); setCookies(await request('cookie.list', { url, tabId: tab.id })); }, 'Cookie 已删除')}><Trash2 size={15} /></button></div>;
})}</div>)}</div>
<div className="rule-editor cookie-editor-pane"><h2>写入 Cookie</h2><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field><Field label="值"><textarea rows={4} value={draft.value} onChange={(event) => setDraft({ ...draft, value: event.target.value })} /></Field><Field label="Domain" hint="留空创建 HostOnly Cookie"><input value={draft.domain || ''} onChange={(event) => setDraft({ ...draft, domain: event.target.value || undefined })} /></Field><Field label="Path"><input value={draft.path} onChange={(event) => setDraft({ ...draft, path: event.target.value })} /></Field><Field label="过期时间"><input type="datetime-local" value={draft.expirationDate ? new Date(draft.expirationDate * 1_000).toISOString().slice(0, 16) : ''} onChange={(event) => setDraft({ ...draft, expirationDate: event.target.value ? new Date(event.target.value).getTime() / 1_000 : undefined })} /></Field><Field label="SameSite"><select value={draft.sameSite} onChange={(event) => setDraft({ ...draft, sameSite: event.target.value as CookieInput['sameSite'] })}><option value="unspecified">Unspecified</option><option value="lax">Lax</option><option value="strict">Strict</option><option value="no_restriction">None</option></select></Field><Field label="Partition top-level site"><input placeholder="https://top.example" value={draft.partitionKey?.topLevelSite || ''} onChange={(event) => setDraft({ ...draft, partitionKey: event.target.value ? { ...draft.partitionKey, topLevelSite: event.target.value } : undefined })} /></Field><label className="check-row"><input type="checkbox" checked={draft.secure} onChange={(event) => setDraft({ ...draft, secure: event.target.checked })} />Secure</label><label className="check-row"><input type="checkbox" checked={draft.httpOnly} onChange={(event) => setDraft({ ...draft, httpOnly: event.target.checked })} />HttpOnly</label><label className="check-row"><input type="checkbox" disabled={!draft.partitionKey} checked={draft.partitionKey?.hasCrossSiteAncestor || false} onChange={(event) => setDraft({ ...draft, partitionKey: { ...draft.partitionKey, hasCrossSiteAncestor: event.target.checked } })} />Cross-site ancestor</label><button className="primary-button" disabled={busy || !url || !draft.name} onClick={() => void run(async () => { await request('cookie.set', { url, ...draft }); setCookies(await request('cookie.list', { url })); }, 'Cookie 已写入')}><Save size={16} />保存 Cookie</button><div className="cookie-transfer"><h2>导入 / 导出</h2><div><select value={transferFormat} onChange={(event) => setTransferFormat(event.target.value as CookieTransferFormat)}><option value="json">JSON</option><option value="netscape">Netscape</option><option value="set-cookie">Set-Cookie</option></select><label className="check-row"><input type="checkbox" checked={includeExportValues} onChange={(event) => setIncludeExportValues(event.target.checked)} />导出原始值</label></div><textarea rows={6} value={importText} onChange={(event) => setImportText(event.target.value)} placeholder="粘贴 Cookie 数据" /><div className="editor-actions"><Button variant="primary" disabled={busy || !importText.trim()} onClick={() => void run(async () => { const result = await request('cookie.import', { url, format: transferFormat, text: importText }); setTransferStatus(`导入 ${result.imported},失败 ${result.failed}${result.warnings.length ? `;${result.warnings.join(';')}` : ''}`); setCookies(await request('cookie.list', { url })); }, 'Cookie 导入完成')}><Upload size={14} />导入</Button><Button variant="ghost" disabled={busy || cookies.length === 0} onClick={() => void run(downloadExport, includeExportValues ? 'Cookie 已导出(包含值)' : 'Cookie 已脱敏导出')}><Download size={14} />导出</Button></div>{transferStatus && <p className="transfer-status">{transferStatus}</p>}</div></div>
<div className="rule-editor cookie-editor-pane"><h2>写入 Cookie</h2><Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field><Field label="值"><textarea rows={4} value={draft.value} onChange={(event) => setDraft({ ...draft, value: event.target.value })} /></Field><Field label="Domain" hint="留空创建 HostOnly Cookie"><input value={draft.domain || ''} onChange={(event) => setDraft({ ...draft, domain: event.target.value || undefined })} /></Field><Field label="Path"><input value={draft.path} onChange={(event) => setDraft({ ...draft, path: event.target.value })} /></Field><Field label="过期时间"><input type="datetime-local" value={draft.expirationDate ? new Date(draft.expirationDate * 1_000).toISOString().slice(0, 16) : ''} onChange={(event) => setDraft({ ...draft, expirationDate: event.target.value ? new Date(event.target.value).getTime() / 1_000 : undefined })} /></Field><Field label="SameSite"><select value={draft.sameSite} onChange={(event) => setDraft({ ...draft, sameSite: event.target.value as CookieInput['sameSite'] })}><option value="unspecified">Unspecified</option><option value="lax">Lax</option><option value="strict">Strict</option><option value="no_restriction">None</option></select></Field><Field label="Partition top-level site"><input placeholder="https://top.example" value={draft.partitionKey?.topLevelSite || ''} onChange={(event) => setDraft({ ...draft, partitionKey: event.target.value ? { ...draft.partitionKey, topLevelSite: event.target.value } : undefined })} /></Field><label className="check-row"><input type="checkbox" checked={draft.secure} onChange={(event) => setDraft({ ...draft, secure: event.target.checked })} />Secure</label><label className="check-row"><input type="checkbox" checked={draft.httpOnly} onChange={(event) => setDraft({ ...draft, httpOnly: event.target.checked })} />HttpOnly</label><label className="check-row"><input type="checkbox" disabled={!draft.partitionKey} checked={draft.partitionKey?.hasCrossSiteAncestor || false} onChange={(event) => setDraft({ ...draft, partitionKey: { ...draft.partitionKey, hasCrossSiteAncestor: event.target.checked } })} />Cross-site ancestor</label><button className="primary-button" disabled={busy || !url || !draft.name || !tab?.id} onClick={() => void run(async () => { if (!tab?.id) throw new Error('请选择目标标签页'); await request('cookie.set', { url, tabId: tab.id, ...draft }); setCookies(await request('cookie.list', { url, tabId: tab.id })); }, 'Cookie 已写入')}><Save size={16} />保存 Cookie</button><div className="cookie-transfer"><h2>导入 / 导出</h2><div><select value={transferFormat} onChange={(event) => setTransferFormat(event.target.value as CookieTransferFormat)}><option value="json">JSON</option><option value="netscape">Netscape</option><option value="set-cookie">Set-Cookie</option></select><label className="check-row"><input type="checkbox" checked={includeExportValues} onChange={(event) => setIncludeExportValues(event.target.checked)} />导出原始值</label></div><textarea rows={6} value={importText} onChange={(event) => setImportText(event.target.value)} placeholder="粘贴 Cookie 数据" /><div className="editor-actions"><Button variant="primary" disabled={busy || !importText.trim() || !tab?.id} onClick={() => void run(async () => { if (!tab?.id) throw new Error('请选择目标标签页'); const result = await request('cookie.import', { url, tabId: tab.id, format: transferFormat, text: importText }); setTransferStatus(`导入 ${result.imported},失败 ${result.failed}${result.warnings.length ? `;${result.warnings.join(';')}` : ''}`); setCookies(await request('cookie.list', { url, tabId: tab.id })); }, 'Cookie 导入完成')}><Upload size={14} />导入</Button><Button variant="ghost" disabled={busy || cookies.length === 0} onClick={() => void run(downloadExport, includeExportValues ? 'Cookie 已导出(包含值)' : 'Cookie 已脱敏导出')}><Download size={14} />导出</Button></div>{transferStatus && <p className="transfer-status">{transferStatus}</p>}</div></div>
</div>
</div>;
}
@@ -498,7 +517,21 @@ function networkLabel(record: NetworkRequestRecord): { host: string; path: strin
}
}
function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; bridge: BridgeStatus; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
function NetworkActivity({
state,
setState,
tab,
bridge,
run,
busy,
}: {
state: ExtensionState;
setState: (state: ExtensionState) => void;
tab?: ActiveTabInfo;
bridge: BridgeStatus;
run: (task: () => Promise<void>, success?: string) => Promise<void>;
busy: boolean;
}) {
const [status, setStatus] = useState<NetworkCaptureStatus>();
const [records, setRecords] = useState<NetworkRequestRecord[]>([]);
const [selectedId, setSelectedId] = useState('');
@@ -510,6 +543,12 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
const [captureHeaders, setCaptureHeaders] = useState(false);
const [captureBody, setCaptureBody] = useState(false);
const [query, setQuery] = useState('');
const transformShared = gatewayShareActive(state.activeGrant, tab);
const shareTransform = async () => {
if (!tab) throw new Error('请先选择需要共享的页面');
setState(await request('grant.create', gatewayShareGrantInput(state, tab)));
};
const load = useCallback(async () => {
if (!tab) return;
@@ -562,6 +601,14 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
const canGeneratePoc = bridge.state === 'connected' && Boolean(bridge.capabilities?.includes('yakit.poc.generate'));
const canPrepareAnalysis = bridge.state === 'connected' && Boolean(bridge.capabilities?.includes('yakit.browser_request.prepare_analysis'));
const captureTarget = status?.active ? status.target : tab ? { tabId: tab.id } : undefined;
const persistenceHint = status?.persistence === 'degraded'
? `会话存储失败,当前记录仅保留在内存中${status.persistenceError ? `:${status.persistenceError}` : ''}`
: status?.persistence === 'memory-only'
? '当前浏览器不提供会话存储,记录仅保留在内存中'
: status?.persistence === 'pending'
? '最新记录正在写入浏览器会话存储'
: status?.persistence === 'persisted' ? '记录已写入浏览器会话存储' : undefined;
const persistenceSuffix = status?.persistence === 'degraded' || status?.persistence === 'memory-only' ? ' · 仅内存' : '';
const start = () => run(async () => {
if (!tab) throw new Error('请选择目标标签页');
@@ -575,7 +622,7 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
return <div className="section-view network-view">
<div className="page-heading"><div><h1>网络活动</h1><p>HTTP 请求、表单导航、实时通信与前端加密调用。</p></div><div className="network-heading-actions">
<span className={`capture-state ${status?.active ? 'active' : ''}`}><i />{status?.active ? `${status.count} 条请求` : '未捕获'}</span>
<span className={`capture-state ${status?.active ? 'active' : ''}`} title={persistenceHint}><i />{status?.active ? `${status.count} 条请求${persistenceSuffix}` : '未捕获'}</span>
{status?.active ? <Button variant="ghost" disabled={busy || !captureTarget} onClick={() => void run(async () => { setStatus(await request('network.capture.stop', captureTarget!)); setRecords([]); setSelectedId(''); }, '网络捕获已停止')}><Square size={14} />停止</Button> : <Button variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void start()}><Play size={14} />开始捕获</Button>}
</div></div>
@@ -614,7 +661,15 @@ function NetworkActivity({ tab, bridge, run, busy }: { tab?: ActiveTabInfo; brid
</aside>
</div>}
<RecordingWorkspace tab={tab} busy={busy} run={run} />
<RecordingWorkspace
tab={tab}
busy={busy}
run={run}
gatewayShared={transformShared}
gatewayShareExpiresAt={transformShared ? state.activeGrant?.expiresAt : undefined}
gatewayBridgeConnected={bridge.state === 'connected'}
onShareGateway={shareTransform}
/>
</div>;
}
+155 -409
View File
@@ -1,4 +1,11 @@
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
import {
PAGE_RECORDER_REQUEST_EVENT,
PAGE_RECORDER_RESPONSE_EVENT,
type PageRecorderBridgeCommand,
type PageRecorderBridgeRequest,
type PageRecorderBridgeResponse,
} from '@/features/browser-recording/bridge-protocol';
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
import { executeRequestTransaction, executeSideEffectFreeCallable } from '@/features/page-callable/request-transaction';
import { callableExecutionPolicy, settleCallableResult } from '@/features/page-callable/execution';
@@ -16,11 +23,33 @@ import {
createCommunicationBoundaryRuntime,
type CommunicationBoundaryRuntime,
} from '@/features/browser-recording/main-world/boundaries/communication';
import {
createNetworkBoundaryRuntime,
type NetworkBoundaryRuntime,
} from '@/features/browser-recording/main-world/boundaries/network';
import {
createRequestPreparationRuntime,
type RequestPreparationRuntime,
} from '@/features/browser-recording/main-world/boundaries/request-preparation';
import {
createEncodingTransformRuntime,
type EncodingTransformRuntime,
} from '@/features/browser-recording/main-world/transforms/encoding';
import {
createLibraryTransformRuntime,
type LibraryTransformRuntime,
} from '@/features/browser-recording/main-world/transforms/library-transform';
import {
createRecordingEvidenceRuntime,
type RecordingEvidenceRuntime,
} from '@/features/browser-recording/main-world/evidence';
import {
createRecordingTraceRuntime,
type RecordingTraceContext,
type RecordingTraceRuntime,
} from '@/features/browser-recording/main-world/trace';
import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget';
import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size';
import { ExtensionError } from '@/shared/errors';
import type {
BrowserPageCallableExecution,
@@ -153,6 +182,9 @@ interface RecorderSnapshot {
startedAt?: number;
count: number;
droppedCount: number;
retainedCallCount: number;
retainedCallBytes: number;
retainedCallDroppedCount: number;
options?: RecorderOptions;
events: RecordingEvent[];
callables: PageCallableMetadata[];
@@ -198,12 +230,55 @@ export default defineUnlistedScript(() => {
const REGISTRY_KEY = PAGE_RECORDER_REGISTRY_KEY;
const CALLABLE_REGISTRY_KEY = PAGE_CALLABLE_REGISTRY_KEY;
const registry = window as unknown as Record<string, unknown>;
const bridgeScript = document.currentScript;
if (bridgeScript instanceof HTMLScriptElement) {
const bridgeParse = JSON.parse.bind(JSON);
const bridgeStringify = JSON.stringify.bind(JSON);
const allowedCommands = new Set<PageRecorderBridgeCommand>([
'start', 'resume', 'navigation.record', 'stop', 'clear', 'status', 'get',
'callable.create', 'callable.list', 'callable.execute', 'callable.delete', 'transform.execute',
]);
bridgeScript.addEventListener(PAGE_RECORDER_REQUEST_EVENT, (rawEvent) => {
if (!(rawEvent instanceof CustomEvent) || typeof rawEvent.detail !== 'string') return;
void (async () => {
let request: PageRecorderBridgeRequest;
try { request = bridgeParse(rawEvent.detail) as PageRecorderBridgeRequest; } catch { return; }
if (!request?.id || !allowedCommands.has(request.command)) return;
let response: PageRecorderBridgeResponse;
try {
const activeController = registry[REGISTRY_KEY] as RecorderController | undefined;
if (activeController?.version !== PAGE_RECORDER_PROTOCOL_VERSION || typeof activeController.command !== 'function') {
throw new Error('页面录制器尚未就绪');
}
response = {
id: request.id,
ok: true,
result: await Promise.resolve(activeController.command(request.command, request.input || {})),
};
} catch (error) {
response = {
id: request.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
try {
bridgeScript.dispatchEvent(new CustomEvent(PAGE_RECORDER_RESPONSE_EVENT, { detail: bridgeStringify(response) }));
} catch (error) {
const fallback: PageRecorderBridgeResponse = {
id: request.id,
ok: false,
error: `页面录制器结果无法序列化:${error instanceof Error ? error.message : String(error)}`,
};
bridgeScript.dispatchEvent(new CustomEvent(PAGE_RECORDER_RESPONSE_EVENT, { detail: bridgeStringify(fallback) }));
}
})();
});
}
const existing = registry[REGISTRY_KEY] as RecorderController | undefined;
if (existing?.version === PAGE_RECORDER_PROTOCOL_VERSION) return;
const nativeStringify = JSON.stringify.bind(JSON);
const nativeParse = JSON.parse.bind(JSON);
const nativeBtoa = window.btoa.bind(window);
const nativeAtob = window.atob.bind(window);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -214,17 +289,19 @@ export default defineUnlistedScript(() => {
let active = false;
let recordingId: string | undefined;
let startedAt: number | undefined;
let sequence = 0;
let socketSequence = 0;
let uniqueSequence = 0;
let droppedCount = 0;
let events: RecordingEvent[] = [];
let fingerprintSeedLeft = 0x811c9dc5;
let fingerprintSeedRight = 0x9e3779b9;
let deepBreakMatcher: DeepBreakMatcher | undefined;
let restoreAfterDeepBreak = false;
let currentTrace: { traceId: string; interactionId?: string; expiresAt: number } | undefined;
let options: RecorderOptions = { captureValues: false, maxEntries: 200, maxValueBytes: 2_048 };
const evidenceRuntime: RecordingEvidenceRuntime = createRecordingEvidenceRuntime(window, () => options);
const traceRuntime: RecordingTraceRuntime = createRecordingTraceRuntime({
active: () => active,
recordingId: () => recordingId,
captureValues: () => options.captureValues,
maxEntries: () => options.maxEntries,
parentEventId: () => activeEventStack.at(-1),
unique,
});
function pageCallableRegistry(): Map<string, PageCallableRegistryEntry> {
const current = registry[CALLABLE_REGISTRY_KEY];
@@ -256,88 +333,23 @@ export default defineUnlistedScript(() => {
}
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);
return evidenceRuntime.dataType(value);
}
function asBytes(value: unknown): Uint8Array | undefined {
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return undefined;
}
function bytesToHex(bytes: Uint8Array): string {
let output = '';
for (const byte of bytes) output += byte.toString(16).padStart(2, '0');
return output;
return evidenceRuntime.asBytes(value);
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunk = 8_192;
for (let offset = 0; offset < bytes.length; offset += chunk) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
}
return nativeBtoa(binary);
return evidenceRuntime.bytesToBase64(bytes);
}
function fingerprint(value: string): string {
const limit = Math.min(value.length, 262_144);
let left = (fingerprintSeedLeft ^ value.length) >>> 0;
let right = (fingerprintSeedRight ^ Math.imul(value.length, 0x85ebca6b)) >>> 0;
for (let index = 0; index < limit; index += 1) {
const code = value.charCodeAt(index);
left = Math.imul(left ^ code, 0x01000193) >>> 0;
right = Math.imul(right ^ code, 0x85ebca6b) >>> 0;
}
return `v2:${value.length}:${left.toString(16).padStart(8, '0')}${right.toString(16).padStart(8, '0')}`;
return evidenceRuntime.fingerprint(value);
}
function reseedFingerprints(): void {
const seed = new Uint32Array(2);
try {
crypto.getRandomValues(seed);
fingerprintSeedLeft = seed[0] || 0x811c9dc5;
fingerprintSeedRight = seed[1] || 0x9e3779b9;
} catch {
fingerprintSeedLeft = (Date.now() ^ Math.floor(performance.now() * 1_000)) >>> 0;
fingerprintSeedRight = Math.imul(fingerprintSeedLeft ^ 0x9e3779b9, 0x85ebca6b) >>> 0;
}
}
function truncatePreview(value: string): string {
const bytes = encoder.encode(value);
return bytes.byteLength <= options.maxValueBytes ? value : decoder.decode(bytes.slice(0, options.maxValueBytes));
}
function evidenceText(path: string, value: string, encoding: ValueEvidence['encoding']): ValueEvidence {
return {
path,
fingerprint: fingerprint(value),
encoding,
byteLength: encoder.encode(value).byteLength,
preview: options.captureValues ? truncatePreview(value) : undefined,
};
}
function formEncodedEntries(value: string): Array<[string, string]> | undefined {
if (!value.includes('=') || value.length > 262_144) return undefined;
const segments = value.split('&');
if (!segments.length || segments.length > 64) return undefined;
const entries: Array<[string, string]> = [];
for (const segment of segments) {
const separator = segment.indexOf('=');
if (separator <= 0) return undefined;
let key: string;
try { key = decodeURIComponent(segment.slice(0, separator).replace(/\+/g, ' ')); } catch { return undefined; }
if (!/^[\p{L}_$][\p{L}\p{N}_.\[\]$-]{0,127}$/u.test(key)) return undefined;
let item: string;
try { item = decodeURIComponent(segment.slice(separator + 1).replace(/\+/g, ' ')); } catch { return undefined; }
entries.push([key, item]);
}
return entries;
evidenceRuntime.reseed();
}
function collectEvidence(
@@ -347,97 +359,15 @@ export default defineUnlistedScript(() => {
output: ValueEvidence[] = [],
parseStringContainers = true,
): ValueEvidence[] {
if (output.length >= 48 || value === undefined) return output;
if (typeof value === 'string') {
output.push(evidenceText(path, value, 'text'));
if (depth < 3 && (value.startsWith('{') || value.startsWith('['))) {
try { collectEvidence(nativeParse(value), `${path}:json`, depth + 1, output); } catch { /* Not JSON. */ }
}
if (parseStringContainers && depth < 3) {
const entries = formEncodedEntries(value);
for (const [key, item] of entries || []) {
collectEvidence(item, `${path}:form.${key}`, depth + 1, output, false);
}
}
return output;
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
output.push(evidenceText(path, String(value), 'text'));
return output;
}
const bytes = asBytes(value);
if (bytes) {
const bounded = bytes.length > 262_144 ? bytes.subarray(0, 262_144) : bytes;
const hex = bytesToHex(bounded);
const base64 = bytesToBase64(bounded);
output.push({ ...evidenceText(path, hex, 'hex'), byteLength: bytes.byteLength });
if (output.length < 48) output.push({ ...evidenceText(path, base64, 'base64'), byteLength: bytes.byteLength });
return output;
}
if (value instanceof URLSearchParams) {
output.push(evidenceText(path, value.toString(), 'text'));
for (const [key, item] of value) collectEvidence(item, `${path}:form.${key}`, depth + 1, output, false);
return output;
}
if (typeof FormData !== 'undefined' && value instanceof FormData) {
for (const [key, item] of value.entries()) {
collectEvidence(
typeof item === 'string' ? item : `[file ${item.name} ${item.size}]`,
`${path}:form.${key}`,
depth + 1,
output,
false,
);
}
return output;
}
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number'
&& typeof (value as { toString?: unknown }).toString === 'function') {
try { output.push(evidenceText(path, (value as { toString(): string }).toString(), 'hex')); } catch { /* Ignore. */ }
return output;
}
if (value && typeof value === 'object' && depth < 3) {
let entries: Array<[string, unknown]> = [];
try { entries = Object.entries(value as Record<string, unknown>).slice(0, 32); } catch { return output; }
for (const [key, item] of entries) collectEvidence(item, `${path}.${key}`, depth + 1, output);
if (depth === 0) {
try { output.unshift(evidenceText(path, nativeStringify(value), 'json')); } catch { /* Circular object. */ }
}
}
return output.slice(0, 48);
return evidenceRuntime.collect(value, path, depth, output, parseStringContainers);
}
function byteLength(value: unknown): number | undefined {
try {
if (typeof value === 'string') return encoder.encode(value).byteLength;
if (value instanceof Blob) return value.size;
const bytes = asBytes(value);
if (bytes) return bytes.byteLength;
if (value instanceof URLSearchParams) return encoder.encode(value.toString()).byteLength;
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
return Math.max(0, Number((value as { sigBytes: number }).sigBytes));
}
if (value !== undefined) return encoder.encode(nativeStringify(value)).byteLength;
} catch { return undefined; }
return undefined;
return evidenceRuntime.byteLength(value);
}
function preview(value: unknown): string | undefined {
if (!options.captureValues || value === undefined) return undefined;
try {
if (typeof value === 'string') return truncatePreview(value);
const bytes = asBytes(value);
if (bytes) return `[binary ${bytes.byteLength} bytes]`;
if (value instanceof URLSearchParams) return truncatePreview(value.toString());
if (typeof FormData !== 'undefined' && value instanceof FormData) {
return truncatePreview(nativeStringify([...value.entries()].map(([key, item]) => [key, typeof item === 'string' ? item : `[file ${item.size} bytes]`])));
}
if (value && typeof value === 'object' && typeof (value as { toString?: unknown }).toString === 'function') {
const text = (value as { toString(): string }).toString();
return truncatePreview(text === '[object Object]' ? nativeStringify(value) : text);
}
return truncatePreview(String(value));
} catch { return `[${dataType(value)}]`; }
return evidenceRuntime.preview(value);
}
function stackInfo(): { stack?: string; scriptUrl?: string } {
@@ -463,42 +393,12 @@ export default defineUnlistedScript(() => {
|| communicationBoundaryRuntime.wrapperFunction(wrapperHandleId);
}
function traceContext(): { traceId: string; interactionId?: string } {
const now = performance.now();
if (!currentTrace || currentTrace.expiresAt < now) {
currentTrace = { traceId: unique('trace'), expiresAt: now + 5_000 };
} else currentTrace.expiresAt = now + 5_000;
return currentTrace;
function record(input: RecordingEventInput, context?: RecordingTraceContext): RecordingEvent | undefined {
return traceRuntime.record(input, context) as RecordingEvent | undefined;
}
function record(input: RecordingEventInput, context = traceContext()): RecordingEvent | undefined {
if (!active || !recordingId) return undefined;
sequence += 1;
const item: RecordingEvent = {
id: unique('event'),
sequence,
timestamp: Date.now(),
recordingId,
traceId: context.traceId,
interactionId: context.interactionId,
parentEventId: activeEventStack.at(-1),
source: 'page',
sensitiveCaptured: options.captureValues,
inputs: input.inputs || [],
outputs: input.outputs || [],
...input,
};
events.push(item);
while (events.length > options.maxEntries) {
events.shift();
droppedCount += 1;
}
return item;
}
function observe(factory: () => RecordingEventInput, context?: { traceId: string; interactionId?: string }): RecordingEvent | undefined {
if (!active) return undefined;
try { return record(factory(), context); } catch { droppedCount += 1; return undefined; }
function observe(factory: () => RecordingEventInput, context?: RecordingTraceContext): RecordingEvent | undefined {
return traceRuntime.observe(factory, context) as RecordingEvent | undefined;
}
function bestEffort(operation: () => void): void {
@@ -532,18 +432,6 @@ export default defineUnlistedScript(() => {
};
}
function binaryStringEvidence(value: string, path: string): ValueEvidence[] {
const output = collectEvidence(value, path);
if (output.length >= 48) return output;
try {
const bytes = Uint8Array.from(value, (character) => character.charCodeAt(0));
collectEvidence(bytes, `${path}:bytes`, 0, output);
} catch {
// btoa already validated the binary string; recording remains best effort.
}
return output.slice(0, 48);
}
function interactionLabel(target: EventTarget | null): string {
if (!(target instanceof Element)) return '页面操作';
const element = target.closest('button, a, input, select, textarea, [role]') || target;
@@ -556,7 +444,7 @@ export default defineUnlistedScript(() => {
if (!active) return;
const interactionId = unique('interaction');
const context = { traceId: unique('trace'), interactionId };
currentTrace = { ...context, expiresAt: performance.now() + 5_000 };
traceRuntime.bindContext(context);
observe(() => ({ kind: 'interaction', operation, label: interactionLabel(target) }), context);
}
@@ -571,163 +459,9 @@ export default defineUnlistedScript(() => {
});
}
function headerEvidence(input: HeadersInit | undefined, path: string): ValueEvidence[] {
if (!input) return [];
const output: ValueEvidence[] = [];
try {
for (const [name, value] of new Headers(input)) collectEvidence(value, `${path}.${name.toLowerCase()}`, 0, output);
} catch { /* Invalid headers are handled by the page. */ }
return output;
}
function queryEvidence(input: string | URL | Request): ValueEvidence[] {
const output: ValueEvidence[] = [];
try {
const value = input instanceof Request ? input.url : String(input);
const url = new URL(value, location.href);
for (const [key, item] of url.searchParams) {
collectEvidence(item, `$query.${key}`, 0, output, false);
}
} catch { /* The page owns URL validation. */ }
return output;
}
function patchFetch(): void {
const original = window.fetch;
if (typeof original !== 'function') return;
const wrapped: typeof window.fetch = function recordedFetch(this: Window, input, init) {
observe(() => {
const request = typeof Request !== 'undefined' && input instanceof Request ? input : undefined;
const body = init?.body;
return {
kind: 'fetch', operation: 'request', url: (request?.url || String(input)).slice(0, 8_192),
method: (init?.method || request?.method || 'GET').toUpperCase().slice(0, 32),
byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body),
inputs: [
...collectEvidence(body, '$body'),
...headerEvidence(init?.headers || request?.headers, '$headers'),
...queryEvidence(request || input),
],
...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; headers: Record<string, string> }>();
const prototype = XMLHttpRequest.prototype;
const originalOpen = prototype.open;
const originalSend = prototype.send;
const originalSetHeader = prototype.setRequestHeader;
const wrappedOpen = function recordedOpen(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), headers: {} }));
return Reflect.apply(originalOpen, this, [method, url, ...rest] as Parameters<XMLHttpRequest['open']>);
} as typeof prototype.open;
const wrappedSetHeader = function recordedSetRequestHeader(this: XMLHttpRequest, name: string, value: string) {
bestEffort(() => { const state = states.get(this); if (state) state.headers[name.toLowerCase()] = value; });
return Reflect.apply(originalSetHeader, this, [name, value]);
};
const wrappedSend = function recordedSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
observe(() => {
const state = states.get(this);
return {
kind: 'xhr', operation: 'request', url: state?.url, method: state?.method,
byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body),
inputs: [
...collectEvidence(body, '$body'),
...collectEvidence(state?.headers, '$headers'),
...(state?.url ? queryEvidence(state.url) : []),
], ...stackInfo(),
};
});
return Reflect.apply(originalSend, this, [body]);
};
prototype.open = wrappedOpen;
prototype.setRequestHeader = wrappedSetHeader;
prototype.send = wrappedSend;
restorers.push(() => {
if (prototype.open === wrappedOpen) prototype.open = originalOpen;
if (prototype.setRequestHeader === wrappedSetHeader) prototype.setRequestHeader = originalSetHeader;
if (prototype.send === wrappedSend) prototype.send = originalSend;
});
}
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 { /* Ignore unserializable custom form. */ }
return {
kind: 'form', operation: 'request', url: form.action.slice(0, 8_192), method: form.method.toUpperCase().slice(0, 32),
byteLength: byteLength(body), dataType: 'FormData', inputPreview: preview(body),
inputs: [...collectEvidence(body, '$body'), ...queryEvidence(form.action)], ...stackInfo(),
};
});
};
document.addEventListener('submit', onSubmit, false);
restorers.push(() => document.removeEventListener('submit', onSubmit, false));
}
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 = unique(`socket-${++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 recordedSend(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), inputs: collectEvidence(data, '$frame'), ...stackInfo() }));
return Reflect.apply(originalSend, this, [data]);
};
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), outputs: collectEvidence(event.data, '$frame') }));
const onOpen = () => observe(() => ({ kind: 'websocket', operation: 'open', url: socketUrl, socketId }));
const onClose = (event: CloseEvent) => observe(() => ({ kind: 'websocket', operation: 'close', url: socketUrl, socketId, error: event.wasClean ? undefined : `code=${event.code}` }));
socket.send = wrappedSend;
socket.addEventListener('message', onMessage);
socket.addEventListener('open', onOpen);
socket.addEventListener('close', onClose);
restorers.push(() => {
if (socket.send === wrappedSend) socket.send = originalSend;
socket.removeEventListener('message', onMessage);
socket.removeEventListener('open', onOpen);
socket.removeEventListener('close', onClose);
});
});
return socket;
},
});
window.WebSocket = Wrapped;
restorers.push(() => { if (window.WebSocket === Wrapped) window.WebSocket = Original; });
}
function retainedCallBytes(args: unknown[]): number {
let total = 0;
for (const value of args) {
const size = byteLength(value);
if (size === undefined && value !== undefined && value !== null
&& !['boolean', 'number', 'bigint', 'function'].includes(typeof value)) {
return 2 * 1024 * 1024 + 1;
}
total += Math.max(0, size ?? 128);
if (total > 2 * 1024 * 1024) return total;
}
return total;
}
function registerHandle(input: Omit<RecordedCallHandle, 'id' | 'retainedBytes'>): string | undefined {
const id = unique('handle');
const retainedBytes = retainedCallBytes(input.args);
const retainedBytes = estimateRetainedCallBytes(input.args);
return handles.add({ id, retainedBytes, ...input }) ? id : undefined;
}
@@ -862,18 +596,34 @@ export default defineUnlistedScript(() => {
},
stackInfo,
emit: (input, context) => {
if (context) currentTrace = { ...context, expiresAt: performance.now() + 5_000 };
if (context) traceRuntime.bindContext(context);
return observe(() => input, context);
},
afterWrapperInvoke: pauseForDeepCapture,
});
const requestPreparationRuntime: RequestPreparationRuntime = createRequestPreparationRuntime(window, {
currentTrace() {
if (!currentTrace || currentTrace.expiresAt < performance.now()) return undefined;
currentTrace.expiresAt = performance.now() + 5_000;
return { traceId: currentTrace.traceId, interactionId: currentTrace.interactionId };
},
const networkBoundaryRuntime: NetworkBoundaryRuntime = createNetworkBoundaryRuntime(window, {
unique,
byteLength,
dataType,
asBytes,
preview,
collectEvidence: (value, path) => collectEvidence(value, path),
stackInfo,
context: () => traceRuntime.context(),
emit: (event, context) => { observe(() => event, context); },
});
const encodingTransformRuntime: EncodingTransformRuntime = createEncodingTransformRuntime(window, {
byteLength,
preview,
collectEvidence: (value, path) => collectEvidence(value, path),
stackInfo,
emit: (event) => { observe(() => ({ kind: 'transform', ...event })); },
});
const libraryTransformRuntime: LibraryTransformRuntime = createLibraryTransformRuntime(window, {
currentTrace: () => traceRuntime.currentContext(),
collectEvidence: (value, path) => collectEvidence(value, path),
byteLength,
dataType,
@@ -882,32 +632,24 @@ export default defineUnlistedScript(() => {
emit: (event, context) => { observe(() => ({ kind: 'transform', ...event }), context); },
});
function patchTransforms(): void {
const originalBtoa = window.btoa;
const originalAtob = window.atob;
const wrappedBtoa = function recordedBtoa(input: string): string {
const output = Reflect.apply(originalBtoa, window, [input]);
observe(() => ({ kind: 'transform', operation: 'base64.encode', inputs: binaryStringEvidence(input, '$input'), outputs: collectEvidence(output, '$output'), inputPreview: preview(input), outputPreview: preview(output), byteLength: byteLength(input), resultByteLength: byteLength(output), ...stackInfo() }));
return output;
};
const wrappedAtob = function recordedAtob(input: string): string {
const output = Reflect.apply(originalAtob, window, [input]);
observe(() => ({ kind: 'transform', operation: 'base64.decode', inputs: collectEvidence(input, '$input'), outputs: collectEvidence(output, '$output'), inputPreview: preview(input), outputPreview: preview(output), byteLength: byteLength(input), resultByteLength: byteLength(output), ...stackInfo() }));
return output;
};
window.btoa = wrappedBtoa;
window.atob = wrappedAtob;
restorers.push(() => {
if (window.btoa === wrappedBtoa) window.btoa = originalBtoa;
if (window.atob === wrappedAtob) window.atob = originalAtob;
});
}
const requestPreparationRuntime: RequestPreparationRuntime = createRequestPreparationRuntime(window, {
currentTrace: () => traceRuntime.currentContext(),
collectEvidence: (value, path) => collectEvidence(value, path),
byteLength,
dataType,
preview,
stackInfo,
emit: (event, context) => { observe(() => ({ kind: 'transform', ...event }), context); },
});
function installObservers(): void {
for (const patch of [patchInteractions, patchFetch, patchXhr, patchForms, patchWebSocket, patchTransforms]) bestEffort(patch);
bestEffort(patchInteractions);
cryptoAdapterRuntime.start();
communicationBoundaryRuntime.start();
networkBoundaryRuntime.start();
requestPreparationRuntime.start();
encodingTransformRuntime.start();
libraryTransformRuntime.start();
}
function stop(): void {
@@ -916,10 +658,13 @@ export default defineUnlistedScript(() => {
expiryTimer = undefined;
cryptoAdapterRuntime.stop();
communicationBoundaryRuntime.stop();
networkBoundaryRuntime.stop();
requestPreparationRuntime.stop();
encodingTransformRuntime.stop();
libraryTransformRuntime.stop();
while (restorers.length) bestEffort(restorers.pop()!);
activeEventStack.length = 0;
currentTrace = undefined;
traceRuntime.releaseContext();
deepBreakMatcher = undefined;
restoreAfterDeepBreak = false;
}
@@ -932,10 +677,19 @@ export default defineUnlistedScript(() => {
}
function snapshot(limit = options.maxEntries): RecorderSnapshot {
const trace = traceRuntime.snapshot(limit);
return {
version: PAGE_RECORDER_PROTOCOL_VERSION, active, recordingId, startedAt, count: events.length, droppedCount,
version: PAGE_RECORDER_PROTOCOL_VERSION,
active,
recordingId,
startedAt,
count: trace.count,
droppedCount: trace.droppedCount,
retainedCallCount: handles.size,
retainedCallBytes: handles.retainedBytes,
retainedCallDroppedCount: handles.droppedCount,
options: startedAt ? { ...options } : undefined,
events: events.slice(-Math.max(0, Math.min(limit, options.maxEntries))),
events: trace.events as RecordingEvent[],
callables: callableMetadata(),
};
}
@@ -1095,14 +849,11 @@ export default defineUnlistedScript(() => {
maxValueBytes: Math.max(256, Math.min(Number(input.maxValueBytes) || 2_048, 8_192)),
expiresAt: typeof input.expiresAt === 'number' ? input.expiresAt : undefined,
};
events = [];
handles.clear();
clearRecordedCallables();
sequence = Number.isSafeInteger(input.sequenceStart) && Number(input.sequenceStart) >= 0
traceRuntime.reset(Number.isSafeInteger(input.sequenceStart) && Number(input.sequenceStart) >= 0
? Number(input.sequenceStart)
: 0;
socketSequence = 0;
droppedCount = 0;
: 0);
recordingId = typeof input.recordingId === 'string' && input.recordingId.trim()
? input.recordingId.trim().slice(0, 160)
: unique('recording');
@@ -1116,9 +867,7 @@ export default defineUnlistedScript(() => {
return snapshot();
}
if (command === 'resume') {
if (Number.isSafeInteger(input.sequenceStart) && Number(input.sequenceStart) >= sequence) {
sequence = Number(input.sequenceStart);
}
if (Number.isSafeInteger(input.sequenceStart)) traceRuntime.advanceSequenceStart(Number(input.sequenceStart));
resumeRecording();
return snapshot();
}
@@ -1172,14 +921,11 @@ export default defineUnlistedScript(() => {
}
if (command === 'clear') {
stop();
events = [];
traceRuntime.reset();
handles.clear();
clearRecordedCallables();
recordingId = undefined;
startedAt = undefined;
sequence = 0;
socketSequence = 0;
droppedCount = 0;
return snapshot();
}
if (command === 'status' || command === 'get') return snapshot(typeof input.limit === 'number' ? input.limit : options.maxEntries);
+1 -1
View File
@@ -54,7 +54,7 @@ function App() {
setBridge(nextBridge);
if (nextTab?.url?.startsWith('http')) {
const [cookies, resolution] = await Promise.all([
request('cookie.list', { url: nextTab.url }).catch(() => []),
request('cookie.list', { url: nextTab.url, tabId: nextTab.id }).catch(() => []),
request('ua.resolve', { url: nextTab.url }).catch(() => undefined),
]);
setCookieCount(cookies.length);
@@ -30,20 +30,20 @@ export function CookieQuickView({ tab, busy, run, onCountChange }: CookieQuickVi
const [loadError, setLoadError] = useState('');
const reload = useCallback(async () => {
if (!url) {
if (!url || !tab?.id) {
setCookies([]);
onCountChange(0);
return;
}
try {
const next = await request('cookie.list', { url });
const next = await request('cookie.list', { url, tabId: tab.id });
setCookies(next);
onCountChange(next.length);
setLoadError('');
} catch (error) {
setLoadError(error instanceof Error ? error.message : String(error));
}
}, [onCountChange, url]);
}, [onCountChange, tab?.id, url]);
useEffect(() => { void reload(); }, [reload]);
@@ -75,8 +75,8 @@ export function CookieQuickView({ tab, busy, run, onCountChange }: CookieQuickVi
};
const saveCookie = () => run(async () => {
if (!url || !draft.name) throw new Error('Cookie 名称不能为空');
await request('cookie.set', { url, ...draft });
if (!url || !tab?.id || !draft.name) throw new Error('Cookie 名称不能为空');
await request('cookie.set', { url, tabId: tab.id, ...draft });
await reload();
closeEditor();
}, editing ? 'Cookie 已更新' : 'Cookie 已创建');