refactor: 移除所有录制功能代码

- 删除录制相关页面组件(RecordReplayPage、ReplayListPage、ReplayPlayerPage)
- 删除录制工具文件(recordEventsDb、recordUtils、useRecorder、tabUtils)
- 清理 background.ts 和 content.ts 中的录制代码
- 更新 App.tsx 移除录制路由
- 清理 messages.tsx 中的消息定义
- 删除 types.tsx 中的录制状态
- 移除 package.json 中的 rrweb 相关依赖
- 更新 wxt.config.ts 移除 offscreen、downloads 权限
- 删除 offscreen 目录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
雨霖铃
2026-03-18 23:57:32 +08:00
parent 789705c33b
commit 4d52cbb12f
18 changed files with 80 additions and 1372 deletions
-257
View File
@@ -1,54 +1,5 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { downloadHtmlInBackground } from '@/utils/recordUtils.tsx';
import { sendMessage, onMessage } from '@/utils/messages';
import { getActiveTabId } from '@/utils/tabUtils';
import {
initRecordingSession,
appendEvents,
streamAllEvents,
deleteRecordingSession,
generateSessionId,
getAllSessions,
getAllEvents,
} from '@/utils/recordEventsDb';
interface RecorderState {
isRecording: boolean;
recordingTabId: number | undefined;
sessionId: string | undefined;
startTime: number | undefined;
}
const STORAGE_KEY = 'recorder_state';
// 从 storage 恢复录制状态
async function loadRecorderState(): Promise<RecorderState> {
try {
const result = await browser.storage.local.get(STORAGE_KEY);
return (result[STORAGE_KEY] as RecorderState) || {
isRecording: false,
recordingTabId: undefined,
sessionId: undefined,
startTime: undefined,
};
} catch (err) {
console.error('[background] Failed to load recorder state:', err);
return { isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined };
}
}
// 保存录制状态到 storage
async function saveRecorderState(state: Partial<RecorderState>) {
try {
const currentState = await loadRecorderState();
await browser.storage.local.set({
[STORAGE_KEY]: { ...currentState, ...state },
});
} catch (err) {
console.error('[background] Failed to save recorder state:', err);
}
}
export default defineBackground(() => {
// 监听扩展安装或更新事件
@@ -92,217 +43,9 @@ export default defineBackground(() => {
console.log(
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
);
// 扩展安装/更新时重置录制状态
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
});
// 监听 Tab 切换 - 如果正在录制且切换到其他 Tab,发出警告
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const state = await loadRecorderState();
if (state.isRecording && state.recordingTabId !== activeInfo.tabId) {
console.warn('[background] 录制中切换到其他 Tab,当前 Tab:', activeInfo.tabId, '录制 Tab:', state.recordingTabId);
// 可选:发送通知给 popup 更新 UI 提示用户
await sendMessage('popup:tab-changed', { currentTabId: activeInfo.tabId, recordingTabId: state.recordingTabId });
}
});
chrome.tabs.onUpdated.addListener((tabId) => {
console.log('加载完成的 Tab ID:', tabId);
});
// 监听 Tab 关闭 - 如果关闭的是录制中的 Tab,自动停止录制
chrome.tabs.onRemoved.addListener(async (tabId) => {
const state = await loadRecorderState();
if (state.isRecording && state.recordingTabId === tabId) {
console.warn('[background] 录制中的 Tab 已关闭,自动停止录制');
// 这里可以调用停止逻辑或通知 popup
await sendMessage('popup:stopped', undefined);
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
}
});
onMessage('popup:check-status', async () => {
console.log('[background] popup:check-status');
const state = await loadRecorderState();
// 如果正在录制,额外检查 content script 的实际状态
if (state.isRecording && state.recordingTabId) {
try {
const result = await sendMessage('content:check-status', undefined, state.recordingTabId);
console.log('[background] content check-status result:', result);
// 如果 content script 返回 false,说明状态不同步,需要重置
if (!result) {
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
return { active: false, startTime: -1 };
}
} catch (err) {
console.error('[background-err] check-status failed:', err);
// Content script 可能已被移除(如页面刷新),重置状态
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
return { active: false, startTime: -1 };
}
}
return { active: state.isRecording, startTime: state.startTime ?? -1 };
});
onMessage('popup:start', async () => {
console.log('[bg] startRecording received');
try {
// 检查是否已经在录制
const currentState = await loadRecorderState();
if (currentState.isRecording) {
console.warn('[bg] 已经在录制中,无法开始新的录制');
return { ok: false, error: 'Already recording' };
}
const tabId = await getActiveTabId();
if (!tabId) {
return { ok: false, error: 'No active tab' };
}
const response = await sendMessage('content:start-recording', undefined, tabId);
if (response.ok) {
// 生成新的会话 ID 并初始化 IndexedDB 会话
const sessionId = generateSessionId();
console.log('[bg] 开始录制,sessionId:', sessionId, 'tabId:', tabId);
await initRecordingSession(sessionId, tabId);
// 保存录制状态到 storage
await saveRecorderState({
isRecording: true,
recordingTabId: tabId,
sessionId,
startTime: Date.now(),
});
console.log('[bg] 录制状态已保存');
await sendMessage('popup:started', undefined);
return { ok: true };
}
return { ok: false, error: response.error };
} catch (error) {
console.error('Failed to start recording in content script', error);
return { ok: false, error: String(error) };
}
});
onMessage('popup:stop', async () => {
console.log('[bg] stopRecording received');
try {
const state = await loadRecorderState();
if (!state.isRecording) {
console.warn('[bg] 当前不在录制状态');
return { ok: false, error: 'Not recording' };
}
const currentTabId = await getActiveTabId();
// Tab 一致性检查 - 允许一定程度的灵活性
if (state.recordingTabId !== currentTabId) {
console.warn(
`[bg] Tab 不一致:录制 Tab=${state.recordingTabId}, 当前 Tab=${currentTabId}`,
);
// 仍然尝试在录制 Tab 上停止(如果该 Tab 还存在)
// 如果需要在当前 Tab 停止,可以移除这个条件判断
}
const targetTabId = state.recordingTabId ?? currentTabId;
const response = await sendMessage('content:stop-recording', undefined, targetTabId);
if (response.ok) {
// 从 IndexedDB 流式读取所有事件并下载回放文件
if (state.sessionId) {
const allEvents: unknown[] = [];
await streamAllEvents(state.sessionId, (events) => {
allEvents.push(...events);
});
downloadHtmlInBackground(allEvents);
// 不再删除会话数据,保留录制历史
}
// 清空状态
await saveRecorderState({
isRecording: false,
recordingTabId: undefined,
sessionId: undefined,
startTime: undefined,
});
await sendMessage('popup:stopped', undefined);
return { ok: true };
}
return { ok: false, error: 'Failed to stop recording in content script' };
} catch (error: unknown) {
console.error('Failed to stop recording in content script', error);
return { ok: false, error: String(error) };
}
});
// 获取所有录制会话
onMessage('popup:get-sessions', async () => {
try {
const sessions = await getAllSessions();
return sessions;
} catch (error) {
console.error('[bg] 获取录制会话失败:', error);
return [];
}
});
// 获取会话事件
onMessage('popup:get-session-events', async (message) => {
try {
const sessionId = message.data;
const events = await getAllEvents(sessionId);
return events;
} catch (error) {
console.error('[bg] 获取会话事件失败:', error);
return [];
}
});
// 删除会话
onMessage('popup:delete-session', async (message) => {
try {
const sessionId = message.data;
await deleteRecordingSession(sessionId);
return { ok: true };
} catch (error) {
console.error('[bg] 删除会话失败:', error);
return { ok: false };
}
});
// 下载会话回放
onMessage('popup:download-session', async (message) => {
try {
const sessionId = message.data;
const events = await getAllEvents(sessionId);
downloadHtmlInBackground(events);
return { ok: true };
} catch (error) {
console.error('[bg] 下载会话失败:', error);
return { ok: false };
}
});
onMessage('content:save-track-events', async (event) => {
const state = await loadRecorderState();
console.log('[bg] 收到事件,当前状态:', state);
if (!state.isRecording) {
console.warn('[bg] 收到事件但当前不在录制状态,丢弃事件');
return false;
}
if (!state.sessionId) {
console.error('[bg] 没有 sessionId,无法存储事件');
return false;
}
// 使用 IndexedDB 分块存储事件
await appendEvents(state.sessionId, [event]);
return true;
});
});
+1 -37
View File
@@ -1,45 +1,9 @@
import '../.wxt/types/imports.d.ts';
import { createRecorder } from '@/utils/useRecorder';
import { onMessage } from '@/utils/messages';
export default defineContentScript({
// matches: ['*://*.google.com/*'],
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
const recorder = createRecorder();
let isRecording = false;
onMessage('content:check-status', () => {
console.log(`[content]${isRecording}`);
return isRecording;
});
onMessage('content:start-recording', async () => {
try {
const started = await recorder.startRecord();
if (started) {
isRecording = true;
return { ok: true };
} else {
return { ok: false, error: 'Failed to start recorder' };
}
} catch (error) {
console.error('Error handling start recording:', error);
return { ok: false };
}
});
onMessage('content:stop-recording', () => {
try {
console.log('[content] stopRecording received');
recorder.stopRecord();
isRecording = false;
return { ok: true };
} catch (error) {
console.error('Error handling stop recording:', error);
return { ok: false };
}
});
// Content script placeholder
},
});
-7
View File
@@ -1,7 +0,0 @@
<!doctype html>
<html>
<head>
<script src="main.tsx" type="module"></script>
</head>
<body></body>
</html>
-28
View File
@@ -1,28 +0,0 @@
// import { useRef } from 'react';
console.log('[offscreen] loaded');
// const events = [];
// chrome.runtime.onMessage.addListener((msg) => {
// if (msg.type === 'SAVE_EVENT') {
// console.log('[offscreen] rrweb event', msg.event);
// events.push(msg.event);
// // 这里你可以存 IndexedDB / memory / file
// }
// if (msg.type === 'SAVE_EVENTS') {
// console.log('[offscreen] SAVE_EVENTS received');
// console.log('[offscreen] total events:', events.length);
// console.log('[offscreen] events:', JSON.stringify(events));
// // const blob = new Blob([JSON.stringify(events)], {
// // type: 'application/json',
// // });
// // reader.onload = () => {
// // chrome.runtime.sendMessage({
// // type: 'DOWNLOAD',
// // dataUrl: reader.result,
// // });
// // };
// // reader.readAsDataURL(blob);
// }
// });
-6
View File
@@ -1,10 +1,7 @@
// App.js
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import TimestampPage from './pages/TimestampPage';
import RecordReplayPage from './pages/RecordReplayPage';
import TestPage from './pages/TestPage';
import ReplayListPage from './pages/ReplayListPage';
import ReplayPlayerPage from './pages/ReplayPlayerPage';
import Navbar from '../../components/Navbar';
import RoutePersistence from '../../components/RoutePersistence';
import './App.css';
@@ -13,8 +10,6 @@ import './App.css';
const navItems = [
{ path: '/test', label: '测试页面', element: <TestPage /> },
{ path: '/', label: '时间戳', element: <TimestampPage /> },
{ path: '/record-replay', label: '录制', element: <RecordReplayPage /> },
{ path: '/replay-list', label: '历史回放', element: <ReplayListPage /> },
];
function App() {
@@ -28,7 +23,6 @@ function App() {
{navItems.map((item) => (
<Route key={item.path} path={item.path} element={item.element} />
))}
<Route path="/replay/:id" element={<ReplayPlayerPage />} />
</Routes>
</div>
</Router>
@@ -1,120 +0,0 @@
import { useEffect, useState, useMemo } from 'react';
import { AppState } from '../types';
import { sendMessage, onMessage } from '@/utils/messages';
import { Button, Container, Stack, Typography, Alert } from '@mui/material';
import { CircularProgress } from '@mui/material';
const RecordReplayPage = () => {
const [status, setStatus] = useState<AppState>(AppState.READ);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isRecording = useMemo(() => status === AppState.RECORDING, [status]);
useEffect(() => {
const unlistenStarted = onMessage('popup:started', () => {
console.log('[popup] Received started message');
setStatus(AppState.RECORDING);
setIsLoading(false);
setError(null);
});
const unlistenStopped = onMessage('popup:stopped', () => {
setStatus(AppState.READ);
setIsLoading(false);
setError(null);
});
const unlistenReady = onMessage('popup:ready', () => {
setStatus(AppState.READ);
setIsLoading(false);
setError(null);
});
sendMessage('popup:check-status', undefined)
.then((res) => {
if (res?.active) {
setStatus(AppState.RECORDING);
} else {
setStatus(AppState.READ);
}
})
.catch((err) => {
console.error(err);
setError('检查录制状态失败');
});
return () => {
unlistenStarted();
unlistenStopped();
unlistenReady();
};
}, []);
const toggleRecording = async () => {
setIsLoading(true);
setError(null);
try {
if (isRecording) {
const result = await sendMessage('popup:stop', undefined);
if (!result?.ok) {
setError(result?.error || '停止录制失败');
setIsLoading(false);
return;
}
} else {
const result = await sendMessage('popup:start', undefined);
if (!result?.ok) {
setError(result?.error || '开始录制失败');
setIsLoading(false);
return;
}
}
} catch (error) {
console.error('Error toggling recording:', error);
setError('操作失败,请重试');
setIsLoading(false);
}
};
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Stack direction="column" spacing={2} sx={{ mb: 2.5 }}>
{error && (
<Alert severity="error" sx={{ fontSize: '0.875rem' }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={1.25} justifyContent="center">
<Button
variant="contained"
size="medium"
color={isRecording ? 'error' : 'primary'}
onClick={toggleRecording}
disabled={isLoading}
sx={{ minWidth: 120 }}
>
{isLoading ? (
<CircularProgress size={20} color="inherit" />
) : isRecording ? (
'停止录制'
) : (
'开始录制'
)}
</Button>
</Stack>
<Typography
variant="body2"
color="text.secondary"
textAlign="center"
sx={{ mt: 1 }}
>
{isRecording ? '正在录制用户操作...' : '点击开始录制用户操作'}
</Typography>
</Stack>
</Container>
);
};
export default RecordReplayPage;
-181
View File
@@ -1,181 +0,0 @@
import { useEffect, useState } from 'react';
import {
Container,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemSecondaryAction,
IconButton,
Typography,
Box,
CircularProgress,
Alert,
Button,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Download as DownloadIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { sendMessage } from '@/utils/messages';
interface Session {
id: string;
startTime: number;
tabId: number;
chunkCount: number;
totalEvents: number;
}
const ReplayListPage = () => {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const data = await sendMessage('popup:get-sessions');
setSessions(data || []);
} catch (err) {
console.error(err);
setError('获取录制历史失败');
} finally {
setIsLoading(false);
}
};
const handlePlay = (sessionId: string) => {
navigate(`/replay/${sessionId}`);
};
const handleDownload = async (sessionId: string) => {
try {
await sendMessage('popup:download-session', sessionId);
} catch (err) {
console.error(err);
setError('下载失败');
}
};
const handleDelete = async (sessionId: string) => {
if (window.confirm('确定要删除这个录制吗?')) {
try {
await sendMessage('popup:delete-session', sessionId);
fetchSessions();
} catch (err) {
console.error(err);
setError('删除失败');
}
}
};
if (isLoading) {
return (
<Container maxWidth={false} sx={{ py: 2.5, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Container>
);
}
if (error) {
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button variant="contained" onClick={fetchSessions}>
</Button>
</Container>
);
}
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
</Typography>
{sessions.length === 0 ? (
<Box textAlign="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
"录制与回放"
</Typography>
</Box>
) : (
<List sx={{ width: '100%', maxWidth: 500, margin: '0 auto' }}>
{sessions.map((session) => (
<ListItem
key={session.id}
disablePadding
sx={{ mb: 1, borderRadius: 1, overflow: 'hidden' }}
>
<ListItemButton
sx={{ borderRadius: 1 }}
onClick={() => handlePlay(session.id)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PlayIcon fontSize="small" />
<Typography variant="body1">
{new Date(session.startTime).toLocaleString()}
</Typography>
</Box>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
: {session.chunkCount * 100}
</Typography>
<Typography variant="caption" color="text.secondary">
Tab: {session.tabId}
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
handleDownload(session.id);
}}
title="下载"
>
<DownloadIcon fontSize="small" />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
handleDelete(session.id);
}}
title="删除"
sx={{ color: 'error.main' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItemButton>
</ListItem>
))}
</List>
)}
</Container>
);
};
export default ReplayListPage;
@@ -1,159 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Container,
Typography,
Box,
CircularProgress,
Alert,
IconButton,
Button,
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Download as DownloadIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { sendMessage } from '@/utils/messages';
const ReplayPlayerPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [eventsData, setEventsData] = useState<unknown[] | null>(null);
const fetchSessionEvents = useCallback(async (sessionId: string) => {
try {
const data = await sendMessage('popup:get-session-events', sessionId);
setEventsData(data || []);
} catch (err) {
console.error(err);
setError('加载录制内容失败');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (id) {
fetchSessionEvents(id);
}
}, [id, fetchSessionEvents]);
const handleDownload = async () => {
if (!id) return;
try {
await sendMessage('popup:download-session', id);
} catch (err) {
console.error(err);
setError('下载失败');
}
};
const handleDelete = async () => {
if (!id) return;
if (window.confirm('确定要删除这个录制吗?')) {
try {
await sendMessage('popup:delete-session', id);
navigate('/record-replay');
} catch (err) {
console.error(err);
setError('删除失败');
}
}
};
if (isLoading) {
return (
<Container maxWidth={false} sx={{ py: 2.5, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Container>
);
}
if (error) {
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6"></Typography>
</Box>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button variant="contained" onClick={() => fetchSessionEvents(id!)}>
</Button>
</Container>
);
}
if (!eventsData || eventsData.length === 0) {
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6"></Typography>
</Box>
<Alert severity="warning">
</Alert>
</Container>
);
}
return (
<Container maxWidth={false} sx={{ py: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<IconButton onClick={() => navigate('/record-replay')} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6"></Typography>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<IconButton onClick={handleDownload} title="下载">
<DownloadIcon />
</IconButton>
<IconButton onClick={handleDelete} title="删除" sx={{ color: 'error.main' }}>
<DeleteIcon />
</IconButton>
</Box>
</Box>
<Box sx={{ borderRadius: 1, overflow: 'hidden', border: 1, borderColor: 'divider' }}>
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
iframe
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
"下载" HTML
</Typography>
<Button
variant="contained"
onClick={handleDownload}
sx={{ mt: 2 }}
startIcon={<DownloadIcon />}
>
</Button>
</Box>
</Box>
<Box sx={{ mt: 2 }}>
<Alert severity="info">
<Typography variant="body2">
<strong></strong> Content Security Policy (CSP)
popup
HTML
</Typography>
</Alert>
</Box>
</Container>
);
};
export default ReplayPlayerPage;
-9
View File
@@ -1,4 +1,3 @@
import { sendMessage } from '@/utils/messages';
import { Button } from '@mui/material';
import { useState, useEffect } from 'react';
@@ -17,11 +16,6 @@ const TestPage = () => {
})();
});
const handleSeedMessage = async () => {
const status = await sendMessage('popup:check-status');
console.log(`[popup]status: ${status}`);
};
const handleAttach = () => {
try {
chrome.debugger.attach({ tabId: tabId }, '1.2', () => {
@@ -61,9 +55,6 @@ const TestPage = () => {
};
return (
<div>
<Button variant="contained" color="primary" onClick={handleSeedMessage}>
</Button>
<Button variant="contained" color="primary" onClick={handleAttach}>
attach
</Button>
-4
View File
@@ -1,4 +0,0 @@
export enum AppState {
READ = 'ready',
RECORDING = 'recording',
}