diff --git a/CLAUDE.md b/CLAUDE.md index 5267c48..82a577e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,3 @@ -``` # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -10,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 核心命令 ### 开发相关 + - `npm run dev` - 启动 Chrome 浏览器的开发模式 - `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式 - `npm run build` - 构建 Chrome 浏览器的生产版本 @@ -19,7 +19,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `npm run compile` - TypeScript 类型检查(不生成文件) - `npm run lint` - 运行 ESLint 检查 +### 测试相关 + +- `npm run test` - 运行所有测试(单次执行) +- `npm run test:watch` - 运行测试并监听文件变化 +- `npm run test:coverage` - 运行测试并生成覆盖率报告 + +**运行单个测试文件:** + +```bash +npx vitest run components/__tests__/CopyButton.test.tsx +``` + +**测试技术栈:** + +- Vitest - 测试框架 +- @testing-library/react - React 组件测试 +- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent) +- jsdom - 浏览器环境模拟 + ### 依赖与准备 + - `npm install` - 安装依赖 - `postinstall` 会自动运行 `wxt prepare` 准备开发环境 - `prepare` 钩子会初始化 Husky Git 钩子 @@ -27,6 +47,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目架构 ### 技术栈 + - **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架 - **前端**: React 19 + TypeScript - **UI 库**: Material UI (MUI) @@ -36,6 +57,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **路由**: React Router DOM ### 目录结构 + ``` ├── components/ # 可复用 UI 组件 @@ -74,39 +96,91 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ├── wxt.config.ts # WXT 配置 └── web-ext.config.ts # WebExtensions 配置 -```` +``` ### 核心功能实现 #### 1. 时间戳转换工具 + - 位置: `components/` 目录下的时间戳相关组件 - 依赖: dayjs 库进行日期处理 - 功能: 支持日期与时间戳的双向转换,支持多种格式 #### 2. 录制与回放功能 + - 位置: `utils/useRecorder.tsx` (核心录制逻辑)、`utils/recordUtils.tsx` (工具函数) - 依赖: rrweb 库 - 存储: IndexedDB (Dexie.js) - `utils/recordEventsDb.ts` - 特点: 支持分块存储录制事件,优化性能 +**录制架构流程:** + +1. **开始录制** (`popup:start` → `background.ts` → `content.ts`) + - Popup 发送开始录制消息 + - Background 生成 sessionId,初始化 IndexedDB 会话 + - Content Script 启动 rrweb 录制器 + +2. **事件存储** (`content:save-track-events`) + - rrweb 捕获事件后通过消息发送给 Background + - Background 使用 IndexedDB 分块存储(每块 100 个事件) + +3. **停止录制** (`popup:stop`) + - Background 从 IndexedDB 流式读取所有事件 + - 生成回放 HTML 文件并下载 + - 保留录制历史(不删除 IndexedDB 数据) + +4. **状态管理** + - 录制状态存储在 `chrome.storage.local` (recorder_state) + - 支持 Tab 切换检测和 Tab 关闭自动停止 + #### 3. 通信系统 + - 位置: `utils/messages.tsx` - 机制: 使用 `@webext-core/messaging` 库实现 - 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 ↔ 离屏文档 +**核心消息类型:** + +- `popup:start` / `popup:stop` - Popup 控制录制 +- `popup:started` / `popup:stopped` - 状态变化通知 +- `popup:check-status` - 查询录制状态 +- `content:start-recording` / `content:stop-recording` - 控制 Content Script +- `content:save-track-events` - 保存录制事件 +- `popup:get-sessions` / `popup:delete-session` - 录制会话管理 + #### 4. 数据存储 + - Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据) - IndexedDB: `utils/recordEventsDb.ts` (用于存储大量录制事件) +**IndexedDB 数据结构:** + +- **sessions** 表: 录制会话元数据 + - `id`: sessionId (string) + - `startTime`: 录制开始时间 (number) + - `tabId`: 录制的标签页 ID (number) + - `chunkCount`: 数据块数量 (number) + - `totalEvents`: 总事件数 (number) + +- **events** 表: 事件数据块 + - `id`: 自增 ID (number) + - `sessionId`: 关联的会话 ID + - `chunkIndex`: 块索引 (number) + - `events`: rrweb 事件数组 (unknown[]) + - `timestamp`: 时间戳 (number) + - CHUNK_SIZE: 100 个事件/块 + ### 关键配置文件 #### wxt.config.ts + - 配置 WXT 框架参数 - 启用 React 模块 - 配置浏览器扩展权限 - Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码) #### manifest 权限 + ```typescript permissions: [ 'storage', // 存储权限 @@ -120,7 +194,7 @@ permissions: [ 'debugger', // 调试器 ], host_permissions: [''] // 访问所有网站 -```` +``` ## 开发注意事项 @@ -141,7 +215,3 @@ host_permissions: [''] // 访问所有网站 - 使用 ESLint 进行代码检查 - Husky 用于 Git 钩子管理 - Lint-staged 确保暂存文件符合规范 - -``` - -``` diff --git a/entrypoints/background.ts b/entrypoints/background.ts index e01c584..ceeb396 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -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 { - 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) { - 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; - }); }); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index f1f4a9e..c354ff5 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -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: [''], 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 }, }); diff --git a/entrypoints/offscreen/index.html b/entrypoints/offscreen/index.html deleted file mode 100644 index dbb6b48..0000000 --- a/entrypoints/offscreen/index.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/entrypoints/offscreen/main.tsx b/entrypoints/offscreen/main.tsx deleted file mode 100644 index 36094fa..0000000 --- a/entrypoints/offscreen/main.tsx +++ /dev/null @@ -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); -// } -// }); diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index e592dab..e978fcb 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -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: }, { path: '/', label: '时间戳', element: }, - { path: '/record-replay', label: '录制', element: }, - { path: '/replay-list', label: '历史回放', element: }, ]; function App() { @@ -28,7 +23,6 @@ function App() { {navItems.map((item) => ( ))} - } /> diff --git a/entrypoints/popup/pages/RecordReplayPage.tsx b/entrypoints/popup/pages/RecordReplayPage.tsx deleted file mode 100644 index 67b80ce..0000000 --- a/entrypoints/popup/pages/RecordReplayPage.tsx +++ /dev/null @@ -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.READ); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(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 ( - - - {error && ( - - {error} - - )} - - - - - - - {isRecording ? '正在录制用户操作...' : '点击开始录制用户操作'} - - - - ); -}; - -export default RecordReplayPage; diff --git a/entrypoints/popup/pages/ReplayListPage.tsx b/entrypoints/popup/pages/ReplayListPage.tsx deleted file mode 100644 index 5f7b115..0000000 --- a/entrypoints/popup/pages/ReplayListPage.tsx +++ /dev/null @@ -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([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(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 ( - - - - ); - } - - if (error) { - return ( - - - {error} - - - - ); - } - - return ( - - - 录制历史 - - - {sessions.length === 0 ? ( - - - 暂无录制历史 - - - 点击"录制与回放"页面的开始按钮进行录制 - - - ) : ( - - {sessions.map((session) => ( - - handlePlay(session.id)} - > - - - - {new Date(session.startTime).toLocaleString()} - - - } - secondary={ - - - 事件数: {session.chunkCount * 100} - - - Tab: {session.tabId} - - - } - /> - - { - e.stopPropagation(); - handleDownload(session.id); - }} - title="下载" - > - - - { - e.stopPropagation(); - handleDelete(session.id); - }} - title="删除" - sx={{ color: 'error.main' }} - > - - - - - - ))} - - )} - - ); -}; - -export default ReplayListPage; diff --git a/entrypoints/popup/pages/ReplayPlayerPage.tsx b/entrypoints/popup/pages/ReplayPlayerPage.tsx deleted file mode 100644 index 16876d9..0000000 --- a/entrypoints/popup/pages/ReplayPlayerPage.tsx +++ /dev/null @@ -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(null); - const [eventsData, setEventsData] = useState(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 ( - - - - ); - } - - if (error) { - return ( - - - navigate('/record-replay')} sx={{ mr: 1 }}> - - - 回放 - - - {error} - - - - ); - } - - if (!eventsData || eventsData.length === 0) { - return ( - - - navigate('/record-replay')} sx={{ mr: 1 }}> - - - 回放 - - - 此会话没有事件数据,请尝试重新录制。 - - - ); - } - - return ( - - - navigate('/record-replay')} sx={{ mr: 1 }}> - - - 回放 - - - - - - - - - - - - - - 由于浏览器安全策略限制,无法在内联 iframe 中直接回放。 - - - 请点击右上角的"下载"按钮,下载完整的回放 HTML 文件到本地查看。 - - - - - - - - - 安全提示:浏览器扩展有严格的 Content Security Policy (CSP) - 限制,禁止内联脚本执行,因此无法在扩展 popup 中直接显示回放内容。 - 下载的 HTML 文件包含了完整的回放代码,可以在任何现代浏览器中直接打开。 - - - - - ); -}; - -export default ReplayPlayerPage; diff --git a/entrypoints/popup/pages/TestPage.tsx b/entrypoints/popup/pages/TestPage.tsx index 318d9e8..eff9d1a 100644 --- a/entrypoints/popup/pages/TestPage.tsx +++ b/entrypoints/popup/pages/TestPage.tsx @@ -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 (
- diff --git a/entrypoints/popup/types.tsx b/entrypoints/popup/types.tsx deleted file mode 100644 index 6cb8e9c..0000000 --- a/entrypoints/popup/types.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export enum AppState { - READ = 'ready', - RECORDING = 'recording', -} diff --git a/package.json b/package.json index 1246653..7dca99f 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,6 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", - "@rrweb/all": "^2.0.0-alpha.18", - "@rrweb/rrweb-plugin-console-record": "^2.0.0-alpha.18", - "@rrweb/rrweb-plugin-console-replay": "^2.0.0-alpha.18", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -34,15 +31,11 @@ "@webext-core/messaging": "^2.3.0", "date-fns": "^4.1.0", "dayjs": "^1.11.19", - "dexie": "^4.2.1", - "dexie-react-hooks": "^4.2.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^6.0.3", "react-router-dom": "^6.30.2", "remark-gfm": "^1.0.0", - "rrweb": "^2.0.0-alpha.4", - "rrweb-player": "^1.0.0-alpha.4", "web-vitals": "^2.1.4" }, "devDependencies": { diff --git a/utils/messages.tsx b/utils/messages.tsx index 1024f10..d6884db 100644 --- a/utils/messages.tsx +++ b/utils/messages.tsx @@ -1,28 +1,8 @@ import { defineExtensionMessaging } from '@webext-core/messaging'; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ProtocolMap { - // --- Popup 相关 --- - 'popup:start': () => { ok: boolean; error?: string }; - 'popup:stop': () => { ok: boolean; error?: string }; - 'popup:started': () => void; - 'popup:stopped': () => void; - 'popup:tab-changed': (data: { currentTabId: number; recordingTabId: number | undefined }) => void; - 'popup:check-status': () => { active: boolean; startTime?: number }; - 'popup:ready': () => void; - 'popup:get-sessions': () => { id: string; startTime: number; tabId: number; chunkCount: number; totalEvents: number }[]; - 'popup:get-session-events': (sessionId: string) => unknown[]; - 'popup:delete-session': (sessionId: string) => { ok: boolean }; - 'popup:download-session': (sessionId: string) => { ok: boolean }; - - // --- Content 相关 --- - 'content:save-track-events': (event: unknown) => boolean; - 'content:start-recording': () => { ok: boolean; error?: string }; - 'content:stop-recording': () => { ok: boolean }; - 'content:check-status': () => boolean; - - // --- Offscreen 相关 --- - 'offscreen:start-recording': (streamId: string) => void; - 'offscreen:stop-recording': () => void; + // Placeholder - 扩展消息协议 } export const { sendMessage, onMessage } = defineExtensionMessaging(); diff --git a/utils/recordEventsDb.ts b/utils/recordEventsDb.ts deleted file mode 100644 index 334d1c8..0000000 --- a/utils/recordEventsDb.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * IndexedDB 工具模块 - 用于分块存储录制事件 - * 避免长时间录制导致内存溢出 - */ - -const DB_NAME = 'recording-events-db'; -const DB_VERSION = 1; -const STORE_NAME = 'events'; -const CHUNK_SIZE = 100; // 每个 chunk 存储的事件数量 - -export interface EventChunk { - id: number; - sessionId: string; - chunkIndex: number; - events: unknown[]; - timestamp: number; -} - -interface RecordingSession { - id: string; - startTime: number; - tabId: number; - chunkCount: number; - totalEvents: number; -} - -let db: IDBDatabase | null = null; - -/** - * 打开数据库连接 - */ -export async function openDB(): Promise { - if (db) return db; - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - db = request.result; - resolve(db); - }; - - request.onupgradeneeded = (event) => { - const database = (event.target as IDBOpenDBRequest).result; - - // 创建事件 chunk 存储 - if (!database.objectStoreNames.contains(STORE_NAME)) { - const store = database.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); - store.createIndex('sessionId', 'sessionId', { unique: false }); - store.createIndex('chunkIndex', 'chunkIndex', { unique: false }); - store.createIndex('sessionId_chunkIndex', ['sessionId', 'chunkIndex'], { unique: true }); - } - - // 创建录制会话存储 - if (!database.objectStoreNames.contains('sessions')) { - const sessionStore = database.createObjectStore('sessions', { keyPath: 'id' }); - sessionStore.createIndex('startTime', 'startTime', { unique: false }); - } - }; - }); -} - -/** - * 初始化新的录制会话 - */ -export async function initRecordingSession(sessionId: string, tabId: number): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const tx = database.transaction('sessions', 'readwrite'); - const store = tx.objectStore('sessions'); - - const session: RecordingSession = { - id: sessionId, - startTime: Date.now(), - tabId, - chunkCount: 0, - totalEvents: 0, - }; - - const request = store.put(session); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -} - -/** - * 获取当前会话的 chunk 数量 - */ -export async function getSessionChunkCount(sessionId: string): Promise { - // 参数验证 - if (!sessionId || typeof sessionId !== 'string') { - console.error('[db] Invalid sessionId for getSessionChunkCount:', sessionId); - return 0; - } - - const database = await openDB(); - - return new Promise((resolve) => { - const tx = database.transaction('sessions', 'readonly'); - const store = tx.objectStore('sessions'); - - try { - const request = store.get(sessionId); - - request.onsuccess = () => { - const session = request.result as RecordingSession | undefined; - resolve(session?.chunkCount || 0); - }; - request.onerror = (e) => { - console.error('[db] Error getting session chunk count:', e); - resolve(0); - }; - } catch (error) { - console.error('[db] Exception getting session chunk count:', error); - resolve(0); - } - }); -} - -/** - * 追加事件到存储(自动分块) - * 当当前 chunk 满时创建新 chunk - */ -export async function appendEvents(sessionId: string, newEvents: unknown[]): Promise { - const database = await openDB(); - const chunkCount = await getSessionChunkCount(sessionId); - - // 获取或创建当前 chunk - let currentChunk: EventChunk | null = null; - let currentChunkIndex = chunkCount > 0 ? chunkCount - 1 : 0; - - if (chunkCount > 0) { - currentChunk = await getChunk(database, sessionId, currentChunkIndex); - } - - // 如果当前 chunk 不存在或已满,创建新 chunk - if (!currentChunk || currentChunk.events.length >= CHUNK_SIZE) { - currentChunkIndex = chunkCount; - currentChunk = { - id: 0, // 将由 IndexedDB 自动分配 - sessionId, - chunkIndex: currentChunkIndex, - events: [], - timestamp: Date.now(), - }; - - // 更新会话的 chunk 计数 - await updateSessionChunkCount(database, sessionId, chunkCount + 1); - } - - // 将新事件添加到当前 chunk - currentChunk.events.push(...newEvents); - - // 保存 chunk - await saveChunk(database, currentChunk); -} - -/** - * 获取指定 chunk - */ -async function getChunk( - database: IDBDatabase, - sessionId: string, - chunkIndex: number, -): Promise { - // 参数验证 - if (!sessionId || typeof sessionId !== 'string') { - console.error('[db] Invalid sessionId for getChunk:', sessionId); - return null; - } - - return new Promise((resolve) => { - try { - const tx = database.transaction(STORE_NAME, 'readonly'); - const store = tx.objectStore(STORE_NAME); - const index = store.index('sessionId_chunkIndex'); - const request = index.get([sessionId, chunkIndex]); - - request.onsuccess = () => resolve(request.result || null); - request.onerror = (e) => { - console.error('[db] Error getting chunk:', e); - resolve(null); - }; - } catch (error) { - console.error('[db] Exception getting chunk:', error); - resolve(null); - } - }); -} - -/** - * 保存 chunk - */ -async function saveChunk(database: IDBDatabase, chunk: EventChunk): Promise { - return new Promise((resolve, reject) => { - const tx = database.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - const request = store.put(chunk); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -} - -/** - * 更新会话的 chunk 计数 - */ -async function updateSessionChunkCount( - database: IDBDatabase, - sessionId: string, - chunkCount: number, -): Promise { - return new Promise((resolve, reject) => { - const tx = database.transaction('sessions', 'readwrite'); - const store = tx.objectStore('sessions'); - const getRequest = store.get(sessionId); - - getRequest.onsuccess = () => { - const session = getRequest.result as RecordingSession | undefined; - if (session) { - session.chunkCount = chunkCount; - const putRequest = store.put(session); - putRequest.onsuccess = () => resolve(); - putRequest.onerror = () => reject(putRequest.error); - } else { - resolve(); - } - }; - getRequest.onerror = () => reject(getRequest.error); - }); -} - -/** - * 流式读取所有事件(按 chunk 顺序) - * 使用回调方式避免一次性加载所有事件到内存 - */ -export async function streamAllEvents( - sessionId: string, - onChunk: (events: unknown[]) => void | Promise, -): Promise { - // 参数验证 - if (!sessionId || typeof sessionId !== 'string') { - console.error('[db] Invalid sessionId for streamAllEvents:', sessionId); - return; - } - - try { - const database = await openDB(); - const chunkCount = await getSessionChunkCount(sessionId); - - console.log(`[db] Streaming ${chunkCount} chunks for session ${sessionId}`); - - for (let i = 0; i < chunkCount; i++) { - const chunk = await getChunk(database, sessionId, i); - if (chunk && chunk.events.length > 0) { - await onChunk(chunk.events); - } - } - } catch (error) { - console.error('[db] Error streaming events:', error); - } -} - -/** - * 获取所有事件(用于下载,一次性加载) - * 注意:这会将所有事件加载到内存,仅在需要导出时调用 - */ -export async function getAllEvents(sessionId: string): Promise { - // 参数验证 - if (!sessionId || typeof sessionId !== 'string') { - console.error('[db] Invalid sessionId for getAllEvents:', sessionId); - return []; - } - - const allEvents: unknown[] = []; - - await streamAllEvents(sessionId, (events) => { - allEvents.push(...events); - }); - - return allEvents; -} - -/** - * 删除录制会话的所有数据 - */ -export async function deleteRecordingSession(sessionId: string): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - // 删除所有相关的 chunk - const chunkTx = database.transaction(STORE_NAME, 'readwrite'); - const chunkStore = chunkTx.objectStore(STORE_NAME); - const index = chunkStore.index('sessionId'); - const request = index.openCursor(IDBKeyRange.only(sessionId)); - - request.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - cursor.delete(); - cursor.continue(); - } - }; - - chunkTx.oncomplete = () => { - // 删除会话记录 - const sessionTx = database.transaction('sessions', 'readwrite'); - const sessionStore = sessionTx.objectStore('sessions'); - const deleteRequest = sessionStore.delete(sessionId); - - deleteRequest.onsuccess = () => resolve(); - deleteRequest.onerror = () => reject(deleteRequest.error); - }; - - chunkTx.onerror = () => reject(chunkTx.error); - }); -} - -/** - * 获取所有录制会话列表 - */ -export async function getAllSessions(): Promise { - const database = await openDB(); - - return new Promise((resolve, reject) => { - const tx = database.transaction('sessions', 'readonly'); - const store = tx.objectStore('sessions'); - const index = store.index('startTime'); - const request = index.openCursor(null, 'prev'); // 按时间倒序排列 - const sessions: RecordingSession[] = []; - - request.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - sessions.push(cursor.value); - cursor.continue(); - } else { - resolve(sessions); - } - }; - - request.onerror = () => reject(request.error); - }); -} - -/** - * 生成唯一的会话 ID - */ -export function generateSessionId(): string { - return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; -} - -/** - * 清理数据库(用于调试或重置) - */ -export async function clearDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(DB_NAME); - request.onsuccess = () => { - db = null; - resolve(); - }; - request.onerror = () => reject(request.error); - }); -} diff --git a/utils/recordUtils.tsx b/utils/recordUtils.tsx deleted file mode 100644 index 2d006d6..0000000 --- a/utils/recordUtils.tsx +++ /dev/null @@ -1,86 +0,0 @@ -export const downloadHtmlInBackground = (events: unknown[]) => { - if (!events || events.length === 0) return; - - // 安全转义 - const safeEventsString = JSON.stringify(events) - .replace(/\\/g, '\\\\') - .replace(/`/g, '\\`') - .replace(/<\/script>/g, '<\\/script>'); - - // 使用固定版本的 rrweb-player,确保与项目使用的 rrweb 版本兼容 - const htmlContent = ` - - - - - RRWeb 回放 - - - - -
- - - - - - -`; - - // 下载逻辑 - const blob = new Blob([htmlContent], { type: 'text/html' }); - const reader = new FileReader(); - reader.onload = () => { - chrome.downloads - .download({ - url: reader.result as string, - filename: `replay-${Date.now()}.html`, - saveAs: true, - }) - .then((r) => console.log('Download started:', r)); - }; - reader.readAsDataURL(blob); -}; diff --git a/utils/tabUtils.ts b/utils/tabUtils.ts deleted file mode 100644 index 95d1dc8..0000000 --- a/utils/tabUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { browser } from 'wxt/browser'; - -/** - * 获取当前活动标签页的 ID - * @returns Promise 返回活动标签页的 ID,如果未找到则返回 undefined - */ -export async function getActiveTabId(): Promise { - const activeTabs = await browser.tabs.query({ - active: true, - currentWindow: true, - }); - const activeTab = activeTabs[0]; - - if (!activeTab) { - throw new Error('No active tab found.'); - } - - // 检查URL是否受限 - if (activeTab?.url) { - const restrictedProtocols = [ - 'chrome:', - 'chrome-extension:', - 'about:', - 'edge:', - 'view-source:', - 'data:', - 'file:', - ]; - if (restrictedProtocols.some((protocol) => activeTab.url!.startsWith(protocol))) { - throw new Error(`Cannot send message to a restricted URL: ${activeTab.url}`); - } - } - - return activeTab.id; -} \ No newline at end of file diff --git a/utils/useRecorder.tsx b/utils/useRecorder.tsx deleted file mode 100644 index 001d4df..0000000 --- a/utils/useRecorder.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as rrweb from 'rrweb'; -import { listenerHandler } from '@rrweb/types'; -import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record'; -import { sendMessage } from '@/utils/messages'; - -export const createRecorder = () => { - let stopFn: listenerHandler | null = null; - - const startRecord = async () => { - try { - const handler = rrweb.record({ - emit(event) { - sendMessage('content:save-track-events', event); - }, - plugins: [getRecordConsolePlugin()], - }); - - stopFn = handler || null; - - console.log('[content] rrweb started'); - return true; - } catch (error) { - console.error('Failed to start recording:', error); - return false; - } - }; - - const stopRecord = () => { - if (stopFn) { - stopFn(); - stopFn = null; - console.log('[content] rrweb stopped'); - } - return false; - }; - - return { startRecord, stopRecord }; -}; diff --git a/wxt.config.ts b/wxt.config.ts index d79a761..3756a12 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -14,8 +14,6 @@ export default defineConfig({ 'activeTab', 'scripting', 'tabs', - 'offscreen', - 'downloads', 'debugger', ], host_permissions: [''],