Compare commits

..
1 Commits
Author SHA1 Message Date
go0p d35912c7cf feat: support tab-wide recording and release notes
CI / Test and build (push) Waiting to run
2026-09-24 14:50:33 +08:00
22 changed files with 464 additions and 94 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ The independent `page-recorder-main-world.js` entrypoint temporarily wraps user-
Each click or submit begins a five-second business Trace. Nested and subsequent events share that Trace. Inputs and outputs are reduced to bounded evidence paths, byte lengths, encodings, and a randomly seeded 64-bit correlation fingerprint. The seed remains inside one page document and is regenerated for every document observer, so fingerprints cannot be compared across document boundaries. Matching an earlier output fingerprint to a later input fingerprint creates an exact Pipeline link. This is evidence of value equality inside one document segment, not proof of semantic causality.
Raw previews are disabled by default. Enabling them requires `browser.recording.sensitive.read` and caps each preview at 8 KiB. A user-started recording is a tab/frame-scoped Session: the current document keeps live hooks and handles, while the background merges bounded document segments into extension-only `storage.session`. A full navigation is recorded as a first-class Trace event; the previous segment is sealed, the destination document receives a new observer with the same Session identity and a synchronized global sequence, and recording continues until explicit stop, expiry, clear, or tab close. The single per-target Session is removed by a new recording, explicit clear, tab close, or browser-session end. Previews are never written to persistent storage or included in audit or AI request-analysis payloads. Recording is bounded to 500 aggregate events, 48 evidence items per side, 1,000 links, and 64 live callable handles per document.
Raw previews are disabled by default. Enabling them requires `browser.recording.sensitive.read` and caps each preview at 8 KiB. A user-started local recording covers every accessible frame in the selected tab and automatically attaches newly committed frames; each frame still owns an independent document Session, hook set, evidence salt, and callable registry, while the UI merges their bounded timelines. Agent-owned recordings remain explicitly frame-scoped. A full navigation is recorded as a first-class Trace event; the previous segment is sealed, the destination document receives a new observer with the same Session identity and a synchronized global sequence, and recording continues until explicit stop, expiry, clear, or tab close. Sessions are removed by a new recording, explicit clear, tab close, or browser-session end. Previews are never written to persistent storage or included in audit or AI request-analysis payloads. Recording is bounded to 500 aggregate events, 48 evidence items per side, 1,000 links, and 64 live callable handles per document.
Navigation is both a business event and a strict execution-context boundary. Full document navigation, reload, browser Back/Forward, same-document History changes and fragment changes are distinguished. If Back/Forward restores the original document from BFCache, its recorder, handles and callables are resumed without clearing earlier evidence; if the browser performs a hard reload, the historical evidence remains but the destroyed closure heap is truthfully unavailable. MAIN-world lifecycle and the tab-scoped Session are separate states, so a temporary document transition no longer appears as a completed recording. A grant-owned recording remains document-bound and stops at navigation instead of silently extending an Agent's authority into a new document.
+3 -1
View File
@@ -338,7 +338,9 @@ Chrome Store 构建声明 Chrome 138+。用户需要在扩展详情页开启“
https://aliyun-oss.yaklang.com/chrome-extension/manifest.json
```
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本按 `variant`(`chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url`、`filename`、`sha256`、`size` 与 `checksum_url`;manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`。
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本的 `notes` 是面向用户的真实更新说明,按 `variant`(`chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url`、`filename`、`sha256`、`size` 与 `checksum_url`;manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`。
每次修改 `package.json` 的版本时,必须同步在 `release-notes.json` 中增加该版本的更新说明;缺失或内容为空会让打包和发布直接失败。YTray 等消费方会原样展示这些说明。
推荐的消费流程:
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "yakit-chrome-client",
"description": "Yakit Browser Extension",
"private": true,
"version": "0.2.6",
"version": "0.2.7",
"type": "module",
"packageManager": "[email protected]",
"scripts": {
+11
View File
@@ -0,0 +1,11 @@
{
"0.2.7": [
"浏览器录制现在覆盖标签页内所有可访问页面,并自动接续后续加载的登录页面。",
"由 YTray 启动的托管浏览器会自动向 Yakit 发起配对连接。",
"修复代理绕过列表无法正常换行编辑的问题,并改善深色界面的选项显示。",
"插件更新页现在会展示随版本发布的真实更新内容。"
],
"0.2.6": [
"自动为用户和 Agent 建立安全的双向浏览器网关。"
]
}
+14 -2
View File
@@ -34,8 +34,13 @@ function artifactFingerprint(artifacts) {
}
function toVersionEntry(entry) {
if (!Array.isArray(entry.notes) || entry.notes.length === 0
|| entry.notes.some((note) => typeof note !== 'string' || note.trim() === '')) {
throw new Error(`release ${entry.version} must contain non-empty notes`);
}
return {
version: entry.version,
notes: entry.notes,
published_at: entry.built_at,
commit: entry.commit ?? null,
artifacts: entry.artifacts.map((a) => ({
@@ -65,6 +70,11 @@ function validate(manifest) {
if (!Array.isArray(versionEntry.artifacts) || versionEntry.artifacts.length === 0) {
throw new Error(`version ${versionEntry.version} has no artifacts`);
}
if (versionEntry.notes !== undefined
&& (!Array.isArray(versionEntry.notes) || versionEntry.notes.length === 0
|| versionEntry.notes.some((note) => typeof note !== 'string' || note.trim() === ''))) {
throw new Error(`version ${versionEntry.version} has invalid notes`);
}
const variants = new Set();
for (const artifact of versionEntry.artifacts) {
if (variants.has(artifact.variant)) throw new Error(`duplicate variant ${artifact.variant} in version ${versionEntry.version}`);
@@ -103,8 +113,10 @@ if (args['existing-manifest']) {
const newEntry = toVersionEntry(entry);
const idx = versions.findIndex((v) => v.version === entry.version);
if (idx >= 0 && artifactFingerprint(versions[idx].artifacts) === artifactFingerprint(entry.artifacts)) {
// Idempotent rerun: keep the original entry (published_at stays stable).
console.log(`version ${entry.version} already in manifest with identical artifacts; kept as-is`);
// An idempotent rerun may backfill release notes without changing immutable
// artifacts or their original publication metadata.
versions[idx] = { ...versions[idx], notes: newEntry.notes };
console.log(`version ${entry.version} already in manifest with identical artifacts; release notes synchronized`);
} else {
if (idx >= 0) {
versions.splice(idx, 1);
+6 -1
View File
@@ -65,6 +65,11 @@ const distDir = resolve(root, String(args.dist ?? 'dist'));
const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
const { version } = pkg;
const notesByVersion = JSON.parse(await readFile(resolve(root, 'release-notes.json'), 'utf8'));
const notes = notesByVersion[version]?.map((note) => String(note).trim()).filter(Boolean);
if (!Array.isArray(notes) || notes.length === 0) {
throw new Error(`release-notes.json must contain at least one note for version ${version}`);
}
let commit = null;
try {
@@ -138,6 +143,6 @@ for (const target of VARIANTS) {
console.log(`packaged ${filename} (${size} bytes, sha256 ${sha256.slice(0, 12)}…)`);
}
const entry = { version, commit, built_at: new Date().toISOString(), artifacts };
const entry = { version, notes, commit, built_at: new Date().toISOString(), artifacts };
await writeFile(resolve(distDir, 'release-entry.json'), `${JSON.stringify(entry, null, 2)}\n`);
console.log(`release entry written: ${resolve(distDir, 'release-entry.json').slice(root.length + 1)} (version ${version})`);
+2
View File
@@ -89,6 +89,8 @@ const manifest = JSON.parse(manifestBytes.toString('utf8'));
assert(manifest.latest === entry.version, `manifest.latest ${manifest.latest} != ${entry.version}`);
const versionEntry = manifest.versions.find((v) => v.version === entry.version);
assert(versionEntry, `manifest has no entry for version ${entry.version}`);
assert(JSON.stringify(versionEntry.notes) === JSON.stringify(entry.notes),
`manifest release notes do not match release entry for ${entry.version}`);
assert(versionEntry.artifacts.length === entry.artifacts.length,
`manifest artifacts count ${versionEntry.artifacts.length} != ${entry.artifacts.length}`);
for (const artifact of entry.artifacts) {
+23 -7
View File
@@ -3,11 +3,16 @@ import { ok } from '../response';
import { requiredDebuggerTarget, requiredRequestTarget } from '../request-context';
import {
browserRecordingStatus,
clearTabBrowserRecording,
clearBrowserRecording,
createRecordedPageCallable,
getBrowserRecording,
getTabBrowserRecording,
startTabBrowserRecording,
startBrowserRecording,
stopTabBrowserRecording,
stopBrowserRecording,
tabBrowserRecordingStatus,
} from '@/features/browser-recording/service';
import {
createCapturedPageCallable,
@@ -35,7 +40,9 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
case 'recording.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const snapshot = await startBrowserRecording(target, input);
const snapshot = input.scope === 'tab'
? await startTabBrowserRecording(target.tabId, input)
: await startBrowserRecording(target, input);
void appendAuditEvent({
category: 'capability',
action: 'recording.start',
@@ -45,18 +52,25 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
});
return ok(snapshot);
}
case 'recording.status': return ok(await browserRecordingStatus(
await requiredRequestTarget(request.payload, sender),
));
case 'recording.status': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(request.payload.scope === 'tab'
? await tabBrowserRecordingStatus(target.tabId)
: await browserRecordingStatus(target));
}
case 'recording.get': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await getBrowserRecording(target, request.payload.limit, true);
const snapshot = request.payload.scope === 'tab'
? await getTabBrowserRecording(target.tabId, request.payload.limit, true)
: await getBrowserRecording(target, request.payload.limit, true);
await stageBrowserProfileEvidence(snapshot);
return ok(snapshot);
}
case 'recording.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await clearBrowserRecording(target, true);
const snapshot = request.payload.scope === 'tab'
? await clearTabBrowserRecording(target.tabId, true)
: await clearBrowserRecording(target, true);
void appendAuditEvent({
category: 'capability',
action: 'recording.clear',
@@ -67,7 +81,9 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
}
case 'recording.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await stopBrowserRecording(target, true);
const snapshot = request.payload.scope === 'tab'
? await stopTabBrowserRecording(target.tabId, true)
: await stopBrowserRecording(target, true);
await stageBrowserProfileEvidence(snapshot);
void appendAuditEvent({
category: 'capability',
+6 -2
View File
@@ -441,8 +441,12 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
await syncManagedInstanceBadge(state.bridge.managedInstance);
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
engineBridge.disconnect();
await stopPairedBrowserTasks();
await engineBridge.connect(state.bridge);
void stopPairedBrowserTasks()
.then(() => engineBridge.connect(state.bridge))
.catch((error) => console.error('Managed browser reconnect failed', error));
} else if (!state.bridge.pairedEngine) {
void engineBridge.startPairing()
.catch((error) => console.error('Managed browser pairing failed', error));
}
return ok(engineBridge.getStatus());
}
+2
View File
@@ -63,6 +63,7 @@ async function send<T>(action: string, payload?: unknown): Promise<T> {
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
allFrames: true,
runAt: 'document_start',
async main(ctx) {
@@ -77,6 +78,7 @@ export default defineContentScript({
console.warn('[Yakit Browser Agent] MAIN-world bridge is unavailable; continuing without page Eval/Invoke.', error);
});
}
if (window.top !== window) return;
const host = document.createElement('yakit-browser-agent');
const shadow = host.attachShadow({ mode: 'open' });
@@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { errorMessage, request } from '@/platform/messaging/runtime';
import type {
ActiveTabInfo, BrowserPageCallable, BrowserPageCallableExecution, BrowserRecordingEvent,
BrowserProfileInferenceCandidate, BrowserRecordingArgumentRole, BrowserRecordingSnapshot,
BrowserProfileInferenceCandidate, BrowserRecordingArgumentRole, BrowserRecordingSnapshot, BrowserTarget,
} from '@/types/models';
import type { CapturedCallableSample } from '@/features/deep-capture/callable-sample';
import { DeepCaptureWorkspace } from '@/features/deep-capture/DeepCaptureWorkspace';
@@ -167,13 +167,6 @@ function navigationPhaseLabel(event: BrowserRecordingEvent): string {
return '浏览器文档边界';
}
function emptySnapshot(tabId: number): BrowserRecordingSnapshot {
return {
status: { active: false, target: { tabId, frameId: 0 }, documentAvailable: true, count: 0, droppedCount: 0 },
events: [], traces: [], links: [], callables: [], profileCandidates: [],
};
}
function shortSample(event?: BrowserRecordingEvent): string | undefined {
const value = event?.inputPreview || event?.inputs.find((item) => item.preview)?.preview;
return value?.trim() || undefined;
@@ -181,14 +174,21 @@ function shortSample(event?: BrowserRecordingEvent): string | undefined {
function eventAvailableInDocument(
event: BrowserRecordingEvent | undefined,
currentDocumentId: string | undefined,
target: BrowserTarget | undefined,
documentAvailable: boolean,
): boolean {
return documentAvailable && Boolean(event) && (
!event?.documentId || !currentDocumentId || event.documentId === currentDocumentId
(event?.frameId === undefined || !target || event.frameId === target.frameId)
&& (!event?.documentId || !target?.documentId || event.documentId === target.documentId)
);
}
function recordingEventTarget(tabId: number | undefined, event?: BrowserRecordingEvent): BrowserTarget | undefined {
return tabId !== undefined && event
? { tabId, frameId: event.frameId ?? 0, documentId: event.documentId }
: undefined;
}
export function RecordingWorkspace({
tab,
busy,
@@ -224,11 +224,8 @@ export function RecordingWorkspace({
return;
}
try {
const target = { tabId, frameId: 0 };
const status = await request('recording.status', target);
const next = status.startedAt
? await request('recording.get', { ...target, limit: 500 })
: emptySnapshot(tabId);
const target = { tabId, frameId: 0, scope: 'tab' as const };
const next = await request('recording.get', { ...target, limit: 500 });
setSnapshot(next);
if (next.status.options) setCaptureValues(next.status.options.captureValues);
setLoadError('');
@@ -272,8 +269,8 @@ export function RecordingWorkspace({
const selectedEvent = snapshot?.events.find((event) => event.id === selectedEventId);
const selectedCallable = snapshot?.callables.find((callable) => callable.id === selectedCallableId);
const recordingTarget = tab ? { tabId: tab.id, frameId: 0 } : undefined;
const documentAvailable = snapshot?.status.documentAvailable !== false;
const callableTarget = snapshot?.status.startedAt && documentAvailable ? snapshot.status.target : undefined;
const tabRecordingTarget = recordingTarget ? { ...recordingTarget, scope: 'tab' as const } : undefined;
const selectedEventTarget = recordingEventTarget(tab?.id, selectedEvent);
useEffect(() => {
if (!selectedEvent) return;
@@ -292,7 +289,7 @@ export function RecordingWorkspace({
const start = () => run(async () => {
if (!tab) throw new Error('请选择目标标签页');
const next = await request('recording.start', {
tabId: tab.id, captureValues, maxEntries: 500, maxValueBytes: 8_192,
tabId: tab.id, scope: 'tab', captureValues, maxEntries: 500, maxValueBytes: 8_192,
});
setSnapshot(next);
setSelectedTraceId('');
@@ -300,16 +297,16 @@ export function RecordingWorkspace({
setCallableResult(undefined);
setPendingGatewayBinding(undefined);
setCaptureCandidate(undefined);
}, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链');
}, captureValues ? '录制已开始;已覆盖标签页内所有页面,短时样本仅保留在本次浏览器会话' : '录制已开始;已覆盖标签页内所有页面与后续登录 frame');
const stop = () => run(async () => {
if (!recordingTarget) return;
setSnapshot(await request('recording.stop', recordingTarget));
if (!tabRecordingTarget) return;
setSnapshot(await request('recording.stop', tabRecordingTarget));
}, '录制已停止,可以继续验证页面函数');
const clear = () => run(async () => {
if (!recordingTarget) return;
setSnapshot(await request('recording.clear', recordingTarget));
if (!tabRecordingTarget) return;
setSnapshot(await request('recording.clear', tabRecordingTarget));
setCallableResult(undefined);
setPendingGatewayBinding(undefined);
setCaptureCandidate(undefined);
@@ -317,11 +314,11 @@ export function RecordingWorkspace({
const createCallable = () => run(async () => {
if (snapshot?.status.active) throw new Error('请先停止录制,再保存页面函数');
if (!selectedEventAvailable || !callableTarget || !selectedEvent?.callHandleId) {
if (!selectedEventAvailable || !selectedEventTarget || !selectedEvent?.callHandleId) {
throw new Error(selectedEvent ? '该调用属于另一个页面文档;返回对应页面现场后才能保存' : '当前事件没有可执行调用句柄');
}
const callable = await request('callable.create', {
...callableTarget, source: 'recording', callHandleId: selectedEvent.callHandleId, name: callableName,
...selectedEventTarget, source: 'recording', callHandleId: selectedEvent.callHandleId, name: callableName,
});
setSnapshot((current) => current ? { ...current, callables: [...current.callables.filter((item) => item.id !== callable.id), callable] } : current);
setSelectedCallableId(callable.id);
@@ -330,17 +327,23 @@ export function RecordingWorkspace({
}, '页面函数已创建');
const executeCallable = () => run(async () => {
if (!callableTarget || !selectedCallable) throw new Error(documentAvailable ? '请选择页面函数' : '页面已经导航,旧文档的页面函数不可再执行');
if (!selectedCallable) throw new Error('请选择页面函数');
let args: unknown;
try { args = JSON.parse(callableArguments); } catch { throw new Error('调用参数必须是有效的 JSON 数组'); }
if (!Array.isArray(args)) throw new Error('调用参数必须是 JSON 数组');
setCallableResult(await request('callable.execute', { ...callableTarget, callableId: selectedCallable.id, args }));
setCallableResult(await request('callable.execute', { ...selectedCallable.target, callableId: selectedCallable.id, args }));
}, '页面函数验证完成');
const deleteCallable = () => run(async () => {
if (!callableTarget || !selectedCallable) return;
const callables = await request('callable.delete', { ...callableTarget, callableId: selectedCallable.id });
setSnapshot((current) => current ? { ...current, callables } : current);
if (!selectedCallable) return;
const callables = await request('callable.delete', { ...selectedCallable.target, callableId: selectedCallable.id });
setSnapshot((current) => current ? {
...current,
callables: [
...current.callables.filter((item) => item.target.frameId !== selectedCallable.target.frameId),
...callables,
],
} : current);
setCallableResult(undefined);
}, '页面函数已删除');
@@ -370,8 +373,7 @@ export function RecordingWorkspace({
? `页面函数句柄 ${(snapshot.status.retainedCallBytes / 1024).toFixed(1)} KiB`
: undefined,
].filter(Boolean).join(' · ');
const currentDocumentId = snapshot?.status.target.documentId;
const selectedEventAvailable = eventAvailableInDocument(selectedEvent, currentDocumentId, documentAvailable);
const documentAvailable = snapshot?.status.documentAvailable !== false;
const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : [];
const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : [];
const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || [];
@@ -382,6 +384,11 @@ export function RecordingWorkspace({
: undefined;
const boundaryCandidates = selectedCandidate ? [] : traceCandidates.filter((candidate) => candidate.request.eventId === selectedEventId);
const relatedCandidate = boundaryCandidates.length === 1 ? boundaryCandidates[0] : undefined;
const selectedEventAvailable = eventAvailableInDocument(
selectedEvent,
selectedCandidate?.target || relatedCandidate?.target || selectedEventTarget,
selectedEvent?.frameId === 0 ? documentAvailable : true,
);
const relatedSourceEvent = relatedCandidate
? snapshot?.events.find((event) => event.id === relatedCandidate.source.eventId)
: undefined;
@@ -394,7 +401,7 @@ export function RecordingWorkspace({
const candidateSourceEvent = selectedCandidate
? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId)
: undefined;
const candidateAvailable = eventAvailableInDocument(candidateSourceEvent, currentDocumentId, documentAvailable);
const candidateAvailable = eventAvailableInDocument(candidateSourceEvent, selectedCandidate?.target, true);
const canDeepCapture = DEEP_CAPTURE_AVAILABLE && selectedEventAvailable && Boolean(selectedEvent
&& ['crypto', 'fetch', 'xhr', 'form', 'beacon', 'worker', 'message'].includes(selectedEvent.kind)
&& (selectedEvent.url || selectedEvent.wrapperHandleId));
@@ -406,8 +413,8 @@ export function RecordingWorkspace({
return;
}
void run(async () => {
if (!recordingTarget) throw new Error('目标标签页不可用');
setSnapshot(await request('recording.stop', recordingTarget));
if (!tabRecordingTarget) throw new Error('目标标签页不可用');
setSnapshot(await request('recording.stop', tabRecordingTarget));
setCallableEditorOpen(true);
}, '录制已停止,请确认页面函数名称');
};
@@ -454,7 +461,7 @@ export function RecordingWorkspace({
throw new Error(`已检测到${pair.direction === 'request' ? '请求' : '响应'}方向,但${candidateStatusLabel(pair)},不能静默保存为单向网关`);
}
const pairEvent = snapshot?.events.find((item) => item.id === pair.source.eventId);
if (!eventAvailableInDocument(pairEvent, currentDocumentId, documentAvailable) || !snapshot?.status.target) {
if (!snapshot || !eventAvailableInDocument(pairEvent, pair.target, true)) {
throw new Error('配对方向属于另一个页面文档,请返回对应页面现场后再生成');
}
const pairInputCount = pair.source.dynamicInputPaths?.length || 1;
@@ -463,7 +470,7 @@ export function RecordingWorkspace({
if (!pairCallable) {
if (!pair.source.callHandleId) throw new Error('配对方向没有可复用的页面调用句柄');
pairCallable = await request('callable.create', {
...snapshot.status.target,
...pair.target,
source: 'recording',
callHandleId: pair.source.callHandleId,
name: `${pair.source.crypto?.algorithm || pair.source.crypto?.operation || pair.source.operation} 页面函数`,
@@ -507,16 +514,16 @@ export function RecordingWorkspace({
if (candidate.sources.length !== 1) {
throw new Error('多调用请求需要先捕获上层业务函数,不能把相互依赖的低层调用拆开回放');
}
if (!candidateAvailable || !recordingTarget || !candidate.source.callHandleId) {
if (!candidateAvailable || !tabRecordingTarget || !candidate.source.callHandleId) {
throw new Error(candidateAvailable ? '推断候选没有可复用的页面调用句柄' : '该函数属于另一个页面文档,请返回对应页面现场后再生成');
}
let currentSnapshot = snapshot;
if (currentSnapshot?.status.active) {
currentSnapshot = await request('recording.stop', recordingTarget);
currentSnapshot = await request('recording.stop', tabRecordingTarget);
setSnapshot(currentSnapshot);
}
if (!currentSnapshot) throw new Error('没有可用的录制现场');
const target = currentSnapshot.status.target;
const target = candidate.target;
if (!target) throw new Error('录制文档已经失效');
const inputCount = candidate.source.dynamicInputPaths?.length || 1;
let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId
@@ -550,7 +557,7 @@ export function RecordingWorkspace({
</div>
<div id="recording-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="recording-mode-tab" hidden={workspaceMode !== 'recording'}><div className="recording-controls">
<label><Switch checked={captureValues} disabled={active || busy} onCheckedChange={setCaptureValues} /><span><strong>保留短时样本</strong><small>关闭时仅保留本次录制的关联指纹</small></span></label>
<label><Switch checked={captureValues} disabled={active || busy} onCheckedChange={setCaptureValues} /><span><strong>保留短时样本</strong><small>自动覆盖当前标签页内所有 frame;关闭时仅保留关联指纹</small></span></label>
<span className="recording-summary" title={persistenceTitle}>{snapshot?.traces.length || 0} 个 Trace · {snapshot?.links.length || 0} 条值关联 · {snapshot?.callables.length || 0} 个页面函数 · {persistenceLabel}{retentionDrops ? ` · ${retentionDrops} 项按预算丢弃` : ''}</span>
<Button size="icon" variant="ghost" aria-label="刷新录制" title="刷新录制" disabled={!tab} onClick={() => void load()}><RefreshCw size={15} /></Button>
<Button size="icon" variant="ghost" aria-label="清空录制" title="清空录制" disabled={!hasRecording || busy} onClick={() => void clear()}><Trash2 size={15} /></Button>
@@ -594,7 +601,12 @@ export function RecordingWorkspace({
<div className="recording-pipeline__body">
{!traceEvents.length ? <div className="recording-column-empty">当前 Trace 没有事件</div> : traceEvents.map((event, index) => {
const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id);
const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable);
const callableAvailable = eventAvailableInDocument(
event,
snapshot?.profileCandidates.find((candidate) => candidate.source.eventId === event.id)?.target
|| recordingEventTarget(tab?.id, event),
event.frameId === 0 ? documentAvailable : true,
);
const flowDirection = recordingEventDirection(event, traceCandidates);
return <div className={`recording-pipeline-step ${event.kind === 'navigation' ? 'is-navigation' : ''}`} key={event.id}>
<span className="recording-step-rail" aria-hidden="true"><i>{String(index + 1).padStart(2, '0')}</i>{index < traceEvents.length - 1 ? <span><ArrowDown size={11} /></span> : null}</span>
@@ -699,6 +711,7 @@ export function RecordingWorkspace({
{DEEP_CAPTURE_AVAILABLE && <div id="deep-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="deep-mode-tab" hidden={workspaceMode !== 'deep'}>
<DeepCaptureWorkspace
tab={tab}
recordingTarget={selectedCandidate?.target || relatedCandidate?.target || selectedEventTarget}
selectedEvent={selectedEvent}
selectedCandidate={captureCandidate || selectedCandidate}
autoArmRequest={autoArmRequest}
+107 -10
View File
@@ -20,7 +20,8 @@ interface RawSnapshot {
const fixture = vi.hoisted(() => ({
storage: new Map<string, unknown>(),
storageFailure: undefined as Error | undefined,
pages: new Map<number, RawSnapshot>(),
pages: new Map<number | string, RawSnapshot>(),
frames: new Map<number, Array<{ tabId: number; frameId: number; documentId: string; url: string; accessible: boolean }>>(),
listeners: {} as Record<string, Listener>,
storageSet: vi.fn(),
}));
@@ -48,11 +49,16 @@ vi.mock('wxt/browser', () => {
runtime: { sendMessage: vi.fn(async () => undefined) },
scripting: {
executeScript: vi.fn(async (details: Record<string, any>) => {
if (details.files) return [{ frameId: 0 }];
const tabId = details.target.tabId as number;
const documentId = details.target.documentIds?.[0] as string | undefined;
const frameId = details.target.frameIds?.[0]
?? fixture.frames.get(tabId)?.find((frame) => frame.documentId === documentId)?.frameId
?? 0;
if (details.files) return [{ frameId }];
const command = details.args?.[2] as string;
const input = (details.args?.[3] || {}) as Record<string, unknown>;
const current = fixture.pages.get(tabId) || rawSnapshot(tabId);
const pageKey = frameId === 0 ? tabId : `${tabId}:${frameId}`;
const current = fixture.pages.get(pageKey) || rawSnapshot(tabId);
if (command === 'start') {
current.active = true;
current.recordingId = typeof input.recordingId === 'string' ? input.recordingId : `recording-${tabId}`;
@@ -67,8 +73,8 @@ vi.mock('wxt/browser', () => {
} else if (command === 'clear') {
Object.assign(current, rawSnapshot(tabId));
}
fixture.pages.set(tabId, current);
return [{ frameId: 0, result: clone(current) }];
fixture.pages.set(pageKey, current);
return [{ frameId, result: clone(current) }];
}),
},
tabs: {
@@ -84,13 +90,19 @@ vi.mock('wxt/browser', () => {
onCreated: event('created'),
},
cookies: {
getAllCookieStores: vi.fn(async () => [{ id: 'store-default', tabIds: [...fixture.pages.keys()] }]),
getAllCookieStores: vi.fn(async () => [{
id: 'store-default',
tabIds: [...new Set([...fixture.pages.keys()].map((key) => Number(String(key).split(':')[0])))],
}]),
},
webNavigation: {
getFrame: vi.fn(async ({ tabId }: { tabId: number }) => ({
url: `https://site-${tabId}.example.test/page`,
documentId: `document-${tabId}`,
})),
getFrame: vi.fn(async ({ tabId, frameId = 0 }: { tabId: number; frameId?: number }) => {
const frame = fixture.frames.get(tabId)?.find((item) => item.frameId === frameId);
return frame || {
url: `https://site-${tabId}.example.test/page`,
documentId: `document-${tabId}`,
};
}),
onBeforeNavigate: event('beforeNavigate'),
onCommitted: event('committed'),
onDOMContentLoaded: event('domContentLoaded'),
@@ -103,6 +115,25 @@ vi.mock('wxt/browser', () => {
};
});
vi.mock('@/features/page-context/frames', () => ({
getFrameInventory: vi.fn(async (tabId: number) => fixture.frames.get(tabId) || [{
tabId,
frameId: 0,
documentId: `document-${tabId}`,
parentFrameId: -1,
url: `https://site-${tabId}.example.test/page`,
origin: `https://site-${tabId}.example.test`,
title: 'Main frame',
name: '',
frameType: 'outermost_frame',
documentLifecycle: 'active',
isTop: true,
sameOrigin: true,
accessible: true,
sandbox: [],
}]),
}));
function rawSnapshot(tabId: number, events: Array<Record<string, unknown>> = []): RawSnapshot {
return {
version: 9,
@@ -159,6 +190,7 @@ describe('browser recording storage, snapshot and retained-value budgets', () =>
vi.setSystemTime(4_102_444_800_000);
fixture.storage.clear();
fixture.pages.clear();
fixture.frames.clear();
fixture.storageFailure = undefined;
fixture.storageSet.mockClear();
});
@@ -256,4 +288,69 @@ describe('browser recording storage, snapshot and retained-value budgets', () =>
globalSessionCount: 1,
});
});
it('records and merges requests from cross-origin child frames at tab scope', async () => {
const tabId = 30;
fixture.frames.set(tabId, [
{
tabId, frameId: 0, documentId: 'document-30', url: 'https://www.jd.com/', accessible: true,
},
{
tabId, frameId: 4, documentId: 'document-passport', url: 'https://passport.jd.com/new/login.aspx', accessible: true,
},
]);
fixture.pages.set(tabId, rawSnapshot(tabId, [recordingEvent(1)]));
fixture.pages.set(`${tabId}:4`, rawSnapshot(tabId, [{
...recordingEvent(2),
traceId: 'trace-passport-login',
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}]));
const service = await freshService();
const snapshot = await service.startTabBrowserRecording(tabId, { captureValues: true });
expect(snapshot.status).toMatchObject({ active: true, scope: 'tab' });
expect(snapshot.events).toEqual(expect.arrayContaining([
expect.objectContaining({ frameId: 0 }),
expect.objectContaining({
frameId: 4,
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}),
]));
expect(snapshot.traces.some((trace) => trace.label === 'POST /uc/loginService')).toBe(true);
});
it('automatically attaches a login frame created after recording starts', async () => {
const tabId = 31;
const top = {
tabId, frameId: 0, documentId: 'document-31', url: 'https://www.jd.com/', accessible: true,
};
fixture.frames.set(tabId, [top]);
fixture.pages.set(tabId, rawSnapshot(tabId));
const service = await freshService();
await service.startTabBrowserRecording(tabId);
fixture.frames.set(tabId, [top, {
tabId, frameId: 7, documentId: 'document-passport-late', url: 'https://passport.jd.com/new/login.aspx', accessible: true,
}]);
fixture.pages.set(`${tabId}:7`, rawSnapshot(tabId, [{
...recordingEvent(3),
traceId: 'trace-late-passport-login',
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}]));
fixture.listeners.committed({
tabId,
frameId: 7,
documentId: 'document-passport-late',
url: 'https://passport.jd.com/new/login.aspx',
timeStamp: Date.now(),
});
await vi.advanceTimersByTimeAsync(0);
const snapshot = await service.getTabBrowserRecording(tabId, 500, true);
expect(snapshot.events).toContainEqual(expect.objectContaining({
frameId: 7,
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}));
});
});
+186 -4
View File
@@ -9,6 +9,7 @@ import { ExtensionError } from '@/shared/errors';
import { inferBrowserTransformProfiles } from '@/features/browser-inference/inference';
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
import { normalizeCallable } from '@/features/page-callable/service';
import { getFrameInventory } from '@/features/page-context/frames';
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from './constants';
import {
buildRecordingLinks,
@@ -543,6 +544,7 @@ function normalizeEvent(value: unknown, allowSensitive: boolean): BrowserRecordi
parentEventId: optionalString(input.parentEventId, 160),
kind: input.kind as BrowserRecordingEvent['kind'],
source: input.source === 'browser' ? 'browser' : 'page',
frameId: Number.isSafeInteger(input.frameId) && Number(input.frameId) >= 0 ? Number(input.frameId) : undefined,
documentId: optionalString(input.documentId, 160),
operation: input.operation.slice(0, 160),
inputs: Array.isArray(input.inputs)
@@ -744,9 +746,11 @@ function snapshotFromEvents(
}
function snapshotFrom(target: BrowserTarget, raw: RawRecorderSnapshot): BrowserRecordingSnapshot {
const events = raw.events.map((event) => event.documentId || !target.documentId
? event
: { ...event, documentId: target.documentId });
const events = raw.events.map((event) => ({
...event,
frameId: target.frameId,
documentId: event.documentId || target.documentId,
}));
const callables = raw.callables
.map((item) => normalizeCallable(item, target))
.filter((item): item is BrowserPageCallable => Boolean(item));
@@ -792,6 +796,7 @@ function mergeSessionSnapshot(
globalSessionCount: previous?.status.globalSessionCount,
persistence: previous?.status.persistence,
persistenceError: previous?.status.persistenceError,
scope: previous?.status.scope || current.status.scope,
} : {}),
...status,
}, events, current.callables);
@@ -875,6 +880,7 @@ function applyNavigation(
parentEventId: existing?.parentEventId,
kind: 'navigation',
source: 'browser',
frameId: target.frameId,
documentId: navigation.previousDocumentId || existing?.documentId,
operation: navigationOperation(navigation),
label: navigationLabel(navigation),
@@ -904,6 +910,7 @@ export async function startBrowserRecording(
target: BrowserTarget,
input?: Partial<BrowserRecordingOptions>,
owner: OwnedRecording['owner'] = { kind: 'local' },
scope: BrowserRecordingStatus['scope'] = 'frame',
): Promise<BrowserRecordingSnapshot> {
if (expiredGrantOwner(owner)) throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
const options = normalizeOptions(input);
@@ -918,6 +925,7 @@ export async function startBrowserRecording(
if (!raw.startedAt) throw new ExtensionError('recorder_unavailable', '页面录制器尚未在目标文档就绪');
ownedRecordings.set(targetKey(target), { target, owner });
const snapshot = snapshotFrom(target, raw);
snapshot.status.scope = scope;
snapshot.status.isolationContextId = tab.isolationContextId;
snapshot.status.cookieStoreId = tab.cookieStoreId;
snapshot.status.pageUrl = await currentPageUrl(target);
@@ -980,6 +988,167 @@ export async function getBrowserRecording(target: BrowserTarget, limit = MAX_ENT
return recordingSnapshotForScope(snapshot, allowSensitive);
}
function emptyTabRecording(tabId: number): BrowserRecordingSnapshot {
return {
status: {
active: false,
scope: 'tab',
target: { tabId, frameId: 0 },
documentAvailable: true,
count: 0,
droppedCount: 0,
},
events: [],
traces: [],
links: [],
callables: [],
profileCandidates: [],
};
}
function localTabSnapshots(tabId: number): BrowserRecordingSnapshot[] {
return [...latestSnapshots.entries()].flatMap(([key, snapshot]) => {
if (snapshot.status.target.tabId !== tabId) return [];
const owner = ownedRecordings.get(key)?.owner || sessionOwners.get(key);
return owner?.kind === 'grant' ? [] : [snapshot];
});
}
async function tabFrameTargets(tabId: number): Promise<BrowserTarget[]> {
const frames = await getFrameInventory(tabId);
return frames
.filter((frame) => frame.accessible && /^https?:/i.test(frame.url))
.slice(0, RECORDING_MAX_SESSIONS)
.map(({ frameId, documentId }) => ({ tabId, frameId, documentId }));
}
function summedStatus(
snapshots: BrowserRecordingSnapshot[],
key: 'droppedCount' | 'budgetDroppedCount' | 'previewDroppedCount' | 'retainedBytes'
| 'retainedPreviewBytes' | 'retainedCallCount' | 'retainedCallBytes' | 'retainedCallDroppedCount',
): number {
return snapshots.reduce((total, snapshot) => total + (snapshot.status[key] || 0), 0);
}
function mergedPersistence(snapshots: BrowserRecordingSnapshot[]): BrowserRecordingStatus['persistence'] {
const states = snapshots.map((snapshot) => snapshot.status.persistence);
if (states.includes('degraded')) return 'degraded';
if (states.includes('memory-only')) return 'memory-only';
if (states.includes('pending')) return 'pending';
return states.includes('persisted') ? 'persisted' : undefined;
}
export function mergeTabRecordingSnapshots(
tabId: number,
snapshots: BrowserRecordingSnapshot[],
limit = MAX_ENTRIES,
): BrowserRecordingSnapshot {
if (!snapshots.length) return emptyTabRecording(tabId);
const primary = snapshots.find((snapshot) => snapshot.status.target.frameId === 0) || snapshots[0];
const byEventId = new Map<string, BrowserRecordingEvent>();
for (const snapshot of snapshots) {
for (const event of snapshot.events) {
byEventId.set(event.id, { ...event, frameId: event.frameId ?? snapshot.status.target.frameId });
}
}
const events = [...byEventId.values()]
.sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence || left.id.localeCompare(right.id))
.slice(-Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)));
const eventIds = new Set(events.map((event) => event.id));
const links = buildRecordingLinks(events);
const callables = [...new Map(snapshots.flatMap((snapshot) => snapshot.callables).map((item) => [item.id, item])).values()];
const profileCandidates = [...new Map(snapshots.flatMap((snapshot) => snapshot.profileCandidates)
.filter((candidate) => eventIds.has(candidate.request.eventId) && candidate.sources.every((source) => eventIds.has(source.eventId)))
.map((candidate) => [candidate.id, candidate])).values()];
const startedAt = snapshots.reduce<number | undefined>((oldest, snapshot) => {
const next = snapshot.status.startedAt;
return next === undefined ? oldest : oldest === undefined ? next : Math.min(oldest, next);
}, undefined);
return {
status: {
...primary.status,
active: snapshots.some((snapshot) => snapshot.status.active),
scope: 'tab',
target: primary.status.target,
startedAt,
count: events.length,
droppedCount: summedStatus(snapshots, 'droppedCount'),
budgetDroppedCount: summedStatus(snapshots, 'budgetDroppedCount'),
previewDroppedCount: summedStatus(snapshots, 'previewDroppedCount'),
retainedBytes: summedStatus(snapshots, 'retainedBytes'),
retainedPreviewBytes: summedStatus(snapshots, 'retainedPreviewBytes'),
retainedCallCount: summedStatus(snapshots, 'retainedCallCount'),
retainedCallBytes: summedStatus(snapshots, 'retainedCallBytes'),
retainedCallDroppedCount: summedStatus(snapshots, 'retainedCallDroppedCount'),
globalRetainedBytes: Math.max(...snapshots.map((snapshot) => snapshot.status.globalRetainedBytes || 0)),
globalSessionCount: Math.max(...snapshots.map((snapshot) => snapshot.status.globalSessionCount || 0)),
persistence: mergedPersistence(snapshots),
persistenceError: snapshots.find((snapshot) => snapshot.status.persistenceError)?.status.persistenceError,
endedReason: snapshots.some((snapshot) => snapshot.status.active) ? undefined : primary.status.endedReason,
},
events,
links,
traces: buildRecordingTraces(events, links),
callables,
profileCandidates,
};
}
export async function startTabBrowserRecording(
tabId: number,
input?: Partial<BrowserRecordingOptions>,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
await Promise.all(localTabSnapshots(tabId).map((snapshot) => clearBrowserRecording(snapshot.status.target)));
const targets = await tabFrameTargets(tabId);
const top = targets.find((target) => target.frameId === 0);
if (!top) throw new ExtensionError('target_unavailable', '标签页主文档当前不可录制');
const snapshots = [await startBrowserRecording(top, input, { kind: 'local' }, 'tab')];
const children = await Promise.allSettled(targets
.filter((target) => target.frameId !== 0)
.map((target) => startBrowserRecording(target, input, { kind: 'local' }, 'tab')));
snapshots.push(...children.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []));
return mergeTabRecordingSnapshots(tabId, snapshots);
}
export async function getTabBrowserRecording(
tabId: number,
limit = MAX_ENTRIES,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const stored = localTabSnapshots(tabId);
const snapshots = await Promise.all(stored.map(async (snapshot) => (
getBrowserRecording(snapshot.status.target, limit, allowSensitive).catch(() => recordingSnapshotForScope(snapshot, allowSensitive))
)));
return mergeTabRecordingSnapshots(tabId, snapshots, limit);
}
export async function tabBrowserRecordingStatus(tabId: number): Promise<BrowserRecordingStatus> {
return (await getTabBrowserRecording(tabId, MAX_ENTRIES, false)).status;
}
export async function stopTabBrowserRecording(
tabId: number,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const snapshots = await Promise.all(localTabSnapshots(tabId).map((snapshot) => (
stopBrowserRecording(snapshot.status.target, allowSensitive)
)));
return mergeTabRecordingSnapshots(tabId, snapshots);
}
export async function clearTabBrowserRecording(
tabId: number,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const snapshots = localTabSnapshots(tabId);
await Promise.all(snapshots.map((snapshot) => clearBrowserRecording(snapshot.status.target, allowSensitive)));
return emptyTabRecording(tabId);
}
export async function clearBrowserRecording(target: BrowserTarget, allowSensitive = false): Promise<BrowserRecordingSnapshot> {
const raw = normalizeRawSnapshot(await executeCommand(target, 'clear').catch(() => ({
version: PAGE_RECORDER_PROTOCOL_VERSION, active: false, count: 0, droppedCount: 0, events: [], callables: [],
@@ -1151,9 +1320,22 @@ async function continueRecordingOnDocument(
documentId: details.documentId,
};
const key = targetKey(target);
const stored = await readSession(target);
let stored = await readSession(target);
if (!stored && details.frameId !== 0 && /^https?:/i.test(details.url)) {
const top = await readSession({ tabId: details.tabId, frameId: 0 });
const topKey = targetKey({ tabId: details.tabId, frameId: 0 });
const owner = ownedRecordings.get(topKey)?.owner || top?.owner;
if (top?.snapshot.status.active && top.snapshot.status.scope === 'tab' && owner?.kind !== 'grant') {
const snapshot = await startBrowserRecording(target, top.snapshot.status.options, { kind: 'local' }, 'tab');
notifyRecordingChanged(details.tabId, 'updated');
stored = { snapshot, owner: { kind: 'local' } };
}
}
if (!stored?.snapshot.status.active || !stored.snapshot.status.recordingId) return;
const previous = stored.snapshot;
if (!previous.status.navigation
&& previous.status.target.documentId === details.documentId
&& previous.status.pageUrl === details.url) return;
const previousNavigation = previous.status.navigation;
const hasTransitionEvidence = Boolean(details.transitionType || details.transitionQualifiers?.length);
const kind = hasTransitionEvidence
@@ -8,7 +8,7 @@ import { errorMessage, request } from '@/platform/messaging/runtime';
import type {
ActiveTabInfo, BrowserDeepCaptureFrame, BrowserDeepCaptureMatcher, BrowserDeepCaptureStatus,
BrowserPageCallable, BrowserPageCallableExecution,
BrowserProfileInferenceCandidate, BrowserRecordingEvent,
BrowserProfileInferenceCandidate, BrowserRecordingEvent, BrowserTarget,
} from '@/types/models';
import './deep-capture-workspace.css';
import { eventMatcher } from './matcher';
@@ -18,6 +18,7 @@ type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
interface DeepCaptureWorkspaceProps {
tab?: ActiveTabInfo;
recordingTarget?: BrowserTarget;
selectedEvent?: BrowserRecordingEvent;
selectedCandidate?: BrowserProfileInferenceCandidate;
autoArmRequest?: number;
@@ -76,6 +77,7 @@ const FRAME_SOURCE_LABELS: Record<BrowserDeepCaptureFrame['sourceKind'], string>
export function DeepCaptureWorkspace({
tab,
recordingTarget,
selectedEvent,
selectedCandidate,
autoArmRequest = 0,
@@ -115,7 +117,13 @@ export function DeepCaptureWorkspace({
const handledAutoCapturePause = useRef(0);
const automaticFlowRequested = useRef(false);
const target = status?.target || (tab ? { tabId: tab.id, frameId: 0 } : undefined);
const baseTarget = useMemo(() => recordingTarget
? { ...recordingTarget }
: tab ? { tabId: tab.id, frameId: 0 } : undefined,
[recordingTarget?.documentId, recordingTarget?.frameId, recordingTarget?.tabId, tab?.id]);
const target = status && baseTarget
&& status.target.tabId === baseTarget.tabId && status.target.frameId === baseTarget.frameId
? status.target : baseTarget;
const paused = status?.state === 'paused' && Boolean(status.pause);
useEffect(() => { statusRef.current = status; }, [status]);
@@ -151,15 +159,15 @@ export function DeepCaptureWorkspace({
return;
}
try {
const nextStatus = await request('deep.capture.status', { tabId: tab.id, frameId: 0 });
const nextStatus = await request('deep.capture.status', baseTarget!);
setStatus(nextStatus);
const nextCallables = await request('callable.list', { tabId: tab.id, frameId: 0 }).catch(() => []);
const nextCallables = await request('callable.list', baseTarget!).catch(() => []);
setCallables(nextCallables);
setLoadError('');
} catch (error) {
setLoadError(errorMessage(error));
}
}, [tab]);
}, [baseTarget?.documentId, baseTarget?.frameId, baseTarget?.tabId, tab]);
useEffect(() => { void load(); }, [load]);
@@ -183,11 +191,11 @@ export function DeepCaptureWorkspace({
}
void run(async () => {
setExecution(undefined);
const next = await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher: suggestedMatcher });
const next = await request('deep.capture.start', { ...baseTarget!, matcher: suggestedMatcher });
automaticFlowRequested.current = true;
setStatus(next);
}, '自动分析已武装,请在目标页面重复刚才的操作');
}, [autoArmRequest, busy, run, status, suggestedMatcher, tab]);
}, [autoArmRequest, baseTarget, busy, run, status, suggestedMatcher, tab]);
useEffect(() => {
if (!autoRecoveryRequest || handledAutoRecoveryRequest.current >= autoRecoveryRequest
@@ -208,10 +216,10 @@ export function DeepCaptureWorkspace({
useEffect(() => {
if (!tab || !['armed', 'paused', 'attached'].includes(status?.state || '')) return undefined;
const interval = window.setInterval(() => void request('deep.capture.status', { tabId: tab.id, frameId: 0 })
const interval = window.setInterval(() => void request('deep.capture.status', baseTarget!)
.then(setStatus).catch((error) => setLoadError(errorMessage(error))), status?.state === 'armed' ? 450 : 1_200);
return () => window.clearInterval(interval);
}, [status?.state, tab]);
}, [baseTarget, status?.state, tab]);
useEffect(() => {
if (!paused || !target) return undefined;
@@ -273,7 +281,7 @@ export function DeepCaptureWorkspace({
: { kind: 'request', urlPattern: urlPattern.trim(), frameHints };
setExecution(undefined);
automaticFlowRequested.current = false;
setStatus(await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher }));
setStatus(await request('deep.capture.start', { ...baseTarget!, matcher }));
}, '深度捕获已武装,请在目标页面重现一次操作');
const resume = () => run(async () => {
+3 -3
View File
@@ -6,7 +6,7 @@ import { Field } from '@/components/ui/field';
import { Switch } from '@/components/ui/switch';
import { request } from '@/platform/messaging/runtime';
import type { ProxyProfile } from '@/types/models';
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
import { normalizeBypass, PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
import type { ProxyViewProps } from './types';
import './proxy-workspace.css';
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from './ProxyStatusBar';
@@ -34,7 +34,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
}, [draft.id]);
const persistDraft = async () => {
const saved = await request('proxy.save', draft);
const saved = await request('proxy.save', { ...draft, bypass: normalizeBypass(draft.bypass) });
setState(saved);
if (draft.authEnabled) {
if (password) await request('proxy.auth.set', { profileId: draft.id, password });
@@ -98,7 +98,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
<Field label="协议"><select value={draft.scheme || 'http'} onChange={(event) => setDraft({ ...draft, scheme: event.target.value as ProxyProfile['scheme'] })}><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks4">SOCKS4</option><option value="socks5">SOCKS5</option></select></Field>
<Field label="主机"><input value={draft.host || ''} onChange={(event) => setDraft({ ...draft, host: event.target.value })} /></Field>
<Field label="端口"><input type="number" min="1" max="65535" value={draft.port || ''} onChange={(event) => setDraft({ ...draft, port: Number(event.target.value) })} /></Field>
<Field label="绕过列表" hint="每行一个域名、IP 或 &lt;local&gt;"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field>
<Field label="绕过列表" hint="每行一个域名、IP 或 &lt;local&gt;"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split(/\r?\n/) })} /></Field>
</>}
{draft.kind === 'pac_script' && <>
<Field label="PAC URL"><input value={draft.pacUrl || ''} onChange={(event) => setDraft({ ...draft, pacUrl: event.target.value, pacScript: '' })} placeholder="https://example.com/proxy.pac" /></Field>
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { normalizeBypass } from './presentation';
describe('normalizeBypass', () => {
it('cleans blank lines only when the proxy profile is saved', () => {
expect(normalizeBypass([' localhost ', '', ' ', '<local>'])).toEqual(['localhost', '<local>']);
});
});
+4
View File
@@ -31,6 +31,10 @@ export function proxyProfileDetail(profile: ProxyProfile): string {
return PROXY_KIND_LABELS[profile.kind];
}
export function normalizeBypass(items: string[]): string[] {
return items.map((item) => item.trim()).filter(Boolean);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes > 100 * 1024 ? 0 : 1)} KB`;
+1 -1
View File
@@ -142,7 +142,7 @@ describe('extension request schemas', () => {
it('validates recording bounds and recorded page callables', () => {
expect(parseExtensionRequest({
action: 'recording.start',
payload: { tabId: 12, frameId: 0, captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
payload: { tabId: 12, frameId: 0, scope: 'tab', captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
}).action).toBe('recording.start');
expect(() => parseExtensionRequest({
action: 'recording.start', payload: { tabId: 12, maxEntries: 501 },
+5 -4
View File
@@ -401,14 +401,15 @@ const payloadSchemas = {
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
'recording.start': v.strictObject({
...targetFields,
scope: v.optional(v.picklist(['frame', 'tab'])),
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
}),
'recording.status': v.strictObject(targetFields),
'recording.get': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'recording.clear': v.strictObject(targetFields),
'recording.stop': v.strictObject(targetFields),
'recording.status': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'recording.get': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])), limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'recording.clear': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'recording.stop': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'callable.create': v.union([
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
+1
View File
@@ -80,6 +80,7 @@ input, select, textarea {
color: var(--foreground);
font-size: var(--text-md);
}
select option, select optgroup { background: var(--surface); color: var(--foreground); }
input, select { height: 36px; padding: 0 11px; }
textarea { min-height: 78px; padding: 9px 11px; line-height: 1.5; resize: vertical; }
input::placeholder, textarea::placeholder { color: var(--muted); }
+5 -5
View File
@@ -154,11 +154,11 @@ export interface ExtensionRequestMap {
'network.capture.send': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakitFuzzerOpenResult };
'network.capture.poc': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakPocGenerateResult };
'network.capture.analysis': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: BrowserRequestAnalysisBundle };
'recording.start': { input: { tabId?: number; frameId?: number; documentId?: string; captureValues?: boolean; maxEntries?: number; maxValueBytes?: number }; output: BrowserRecordingSnapshot };
'recording.status': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingStatus };
'recording.get': { input: { tabId?: number; frameId?: number; documentId?: string; limit?: number }; output: BrowserRecordingSnapshot };
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
'recording.start': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab'; captureValues?: boolean; maxEntries?: number; maxValueBytes?: number }; output: BrowserRecordingSnapshot };
'recording.status': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingStatus };
'recording.get': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab'; limit?: number }; output: BrowserRecordingSnapshot };
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingSnapshot };
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingSnapshot };
'callable.create': { input: ({ tabId?: number; frameId?: number; documentId?: string } & (
| { source: 'recording'; callHandleId: string; name: string; dynamicInputPaths?: string[] }
| { source: 'deep-capture'; strategy: 'selected-frame'; callFrameId: string; name?: string; candidateId?: string }
+2
View File
@@ -559,6 +559,7 @@ export interface BrowserRecordingEvent {
parentEventId?: string;
kind: BrowserRecordingEventKind;
source?: 'page' | 'browser';
frameId?: number;
documentId?: string;
operation: string;
label?: string;
@@ -591,6 +592,7 @@ export interface BrowserRecordingEvent {
export interface BrowserRecordingStatus {
active: boolean;
target: BrowserTarget;
scope?: 'frame' | 'tab';
isolationContextId?: string;
cookieStoreId?: string;
documentAvailable: boolean;