mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 已创建');
|
||||
|
||||
Reference in New Issue
Block a user