refactor: improved tab recording to improve stability (#1632)
* refactor: improved tab recording to improve stability * feat: enable to import session * improve stability * feat: enable to edit session name * prevent duplicate rrweb player in the dev mode
This commit is contained in:
5
.changeset/four-panthers-fly.md
Normal file
5
.changeset/four-panthers-fly.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@rrweb/web-extension": patch
|
||||
---
|
||||
|
||||
web-extension: improve recording stability across tabs and enable session import
|
||||
@@ -1,17 +1,25 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import {
|
||||
type LocalData,
|
||||
EventName,
|
||||
LocalDataKey,
|
||||
MessageName,
|
||||
RecorderStatus,
|
||||
type Settings,
|
||||
type SyncData,
|
||||
ServiceName,
|
||||
SyncDataKey,
|
||||
} from '~/types';
|
||||
import { pauseRecording, resumeRecording } from '~/utils/recording';
|
||||
|
||||
const channel = new Channel();
|
||||
import type {
|
||||
LocalData,
|
||||
RecordStartedMessage,
|
||||
RecordStoppedMessage,
|
||||
Session,
|
||||
Settings,
|
||||
SyncData,
|
||||
} from '~/types';
|
||||
import { isFirefox } from '~/utils';
|
||||
import { addSession } from '~/utils/storage';
|
||||
|
||||
void (async () => {
|
||||
// assign default value to settings of this extension
|
||||
@@ -28,105 +36,215 @@ void (async () => {
|
||||
settings,
|
||||
} as SyncData);
|
||||
|
||||
const events: eventWithTime[] = [];
|
||||
const channel = new Channel();
|
||||
let recorderStatus: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: RecorderStatus.IDLE,
|
||||
activeTabId: -1,
|
||||
};
|
||||
// Reset recorder status when the extension is reloaded.
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
|
||||
channel.on(EventName.StartButtonClicked, async () => {
|
||||
if (recorderStatus.status !== RecorderStatus.IDLE) return;
|
||||
recorderStatus = {
|
||||
status: RecorderStatus.IDLE,
|
||||
activeTabId: -1,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
|
||||
events.length = 0; // clear events before recording
|
||||
const tabId = await channel.getCurrentTabId();
|
||||
if (tabId === -1) return;
|
||||
|
||||
const res = (await channel
|
||||
.requestToTab(tabId, ServiceName.StartRecord, {})
|
||||
.catch(async (error: Error) => {
|
||||
recorderStatus.errorMessage = error.message;
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
})) as RecordStartedMessage;
|
||||
if (!res) return;
|
||||
Object.assign(recorderStatus, {
|
||||
status: RecorderStatus.RECORDING,
|
||||
activeTabId: tabId,
|
||||
startTimestamp: res.startTimestamp,
|
||||
});
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
});
|
||||
|
||||
channel.on(EventName.StopButtonClicked, async () => {
|
||||
if (recorderStatus.status === RecorderStatus.IDLE) return;
|
||||
|
||||
if (recorderStatus.status === RecorderStatus.RECORDING)
|
||||
(await channel
|
||||
.requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {})
|
||||
.catch(() => ({
|
||||
message: MessageName.RecordStopped,
|
||||
endTimestamp: Date.now(),
|
||||
}))) as RecordStoppedMessage;
|
||||
recorderStatus = {
|
||||
status: RecorderStatus.IDLE,
|
||||
activeTabId: -1,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
const title =
|
||||
(await Browser.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then((tabs) => tabs[0]?.title)
|
||||
.catch(() => {
|
||||
// ignore error
|
||||
})) ?? 'new session';
|
||||
const newSession = generateSession(title);
|
||||
await addSession(newSession, events).catch((e) => {
|
||||
recorderStatus.errorMessage = (e as { message: string }).message;
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
});
|
||||
channel.emit(EventName.SessionUpdated, {
|
||||
session: newSession,
|
||||
});
|
||||
events.length = 0;
|
||||
});
|
||||
|
||||
async function pauseRecording(newStatus: RecorderStatus) {
|
||||
if (
|
||||
recorderStatus.status !== RecorderStatus.RECORDING ||
|
||||
recorderStatus.activeTabId === -1
|
||||
)
|
||||
return;
|
||||
|
||||
const stopResponse = (await channel
|
||||
.requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {})
|
||||
.catch(() => {
|
||||
// ignore error
|
||||
})) as RecordStoppedMessage | undefined;
|
||||
Object.assign(recorderStatus, {
|
||||
status: newStatus,
|
||||
activeTabId: -1,
|
||||
pausedTimestamp: stopResponse?.endTimestamp,
|
||||
});
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
}
|
||||
channel.on(EventName.PauseButtonClicked, async () => {
|
||||
if (recorderStatus.status !== RecorderStatus.RECORDING) return;
|
||||
await pauseRecording(RecorderStatus.PAUSED);
|
||||
});
|
||||
|
||||
async function resumeRecording(newTabId: number) {
|
||||
if (
|
||||
![RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
|
||||
recorderStatus.status,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const { startTimestamp, pausedTimestamp } = recorderStatus;
|
||||
// On Firefox, the new tab is not communicable immediately after it is created.
|
||||
if (isFirefox()) await new Promise((r) => setTimeout(r, 50));
|
||||
const pausedTime = pausedTimestamp ? Date.now() - pausedTimestamp : 0;
|
||||
// Decrease the time spent in the pause state and make them look like a continuous recording.
|
||||
events.forEach((event) => {
|
||||
event.timestamp += pausedTime;
|
||||
});
|
||||
const startResponse = (await channel
|
||||
.requestToTab(newTabId, ServiceName.StartRecord, {})
|
||||
.catch((e: { message: string }) => {
|
||||
recorderStatus.errorMessage = e.message;
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
})) as RecordStartedMessage | undefined;
|
||||
if (!startResponse) {
|
||||
// Restore the events data when the recording fails to start.
|
||||
events.forEach((event) => {
|
||||
event.timestamp -= pausedTime;
|
||||
});
|
||||
return;
|
||||
}
|
||||
recorderStatus = {
|
||||
status: RecorderStatus.RECORDING,
|
||||
activeTabId: newTabId,
|
||||
startTimestamp: (startTimestamp || Date.now()) + pausedTime,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
}
|
||||
channel.on(EventName.ResumeButtonClicked, async () => {
|
||||
if (recorderStatus.status !== RecorderStatus.PAUSED) return;
|
||||
recorderStatus.errorMessage = undefined;
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
const tabId = await channel.getCurrentTabId();
|
||||
await resumeRecording(tabId);
|
||||
});
|
||||
|
||||
channel.on(EventName.ContentScriptEmitEvent, (data) => {
|
||||
events.push(data as eventWithTime);
|
||||
});
|
||||
|
||||
// When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab.
|
||||
Browser.tabs.onActivated.addListener((activeInfo) => {
|
||||
Browser.storage.local
|
||||
.get(LocalDataKey.recorderStatus)
|
||||
.then(async (data) => {
|
||||
const localData = data as LocalData;
|
||||
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
|
||||
let statusData = localData[LocalDataKey.recorderStatus];
|
||||
let { status } = statusData;
|
||||
let bufferedEvents: eventWithTime[] | undefined;
|
||||
|
||||
if (status === RecorderStatus.RECORDING) {
|
||||
const result = await pauseRecording(
|
||||
channel,
|
||||
RecorderStatus.PausedSwitch,
|
||||
statusData,
|
||||
).catch(async () => {
|
||||
/**
|
||||
* This error happen when the old tab is closed.
|
||||
* In this case, the recording process would be stopped through Browser.tabs.onRemoved API.
|
||||
* So we just read the new status here.
|
||||
*/
|
||||
const localData = (await Browser.storage.local.get(
|
||||
LocalDataKey.recorderStatus,
|
||||
)) as LocalData;
|
||||
return {
|
||||
status: localData[LocalDataKey.recorderStatus],
|
||||
bufferedEvents,
|
||||
};
|
||||
});
|
||||
if (!result) return;
|
||||
statusData = result.status;
|
||||
status = statusData.status;
|
||||
bufferedEvents = result.bufferedEvents;
|
||||
}
|
||||
if (status === RecorderStatus.PausedSwitch)
|
||||
await resumeRecording(
|
||||
channel,
|
||||
activeInfo.tabId,
|
||||
statusData,
|
||||
bufferedEvents,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// the extension can't access to the tab
|
||||
});
|
||||
void (async () => {
|
||||
if (
|
||||
recorderStatus.status !== RecorderStatus.RECORDING &&
|
||||
recorderStatus.status !== RecorderStatus.PausedSwitch
|
||||
)
|
||||
return;
|
||||
if (activeInfo.tabId === recorderStatus.activeTabId) return;
|
||||
if (recorderStatus.status === RecorderStatus.RECORDING)
|
||||
await pauseRecording(RecorderStatus.PausedSwitch);
|
||||
if (recorderStatus.status === RecorderStatus.PausedSwitch)
|
||||
await resumeRecording(activeInfo.tabId);
|
||||
})();
|
||||
return;
|
||||
});
|
||||
|
||||
// If the recording can't start on an invalid tab, resume it when the tab content is updated.
|
||||
Browser.tabs.onUpdated.addListener(function (tabId, info) {
|
||||
if (info.status !== 'complete') return;
|
||||
Browser.storage.local
|
||||
.get(LocalDataKey.recorderStatus)
|
||||
.then(async (data) => {
|
||||
const localData = data as LocalData;
|
||||
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
|
||||
const { status, activeTabId } = localData[LocalDataKey.recorderStatus];
|
||||
if (status !== RecorderStatus.PausedSwitch || activeTabId === tabId)
|
||||
if (
|
||||
recorderStatus.status !== RecorderStatus.PausedSwitch ||
|
||||
recorderStatus.activeTabId === tabId
|
||||
)
|
||||
return;
|
||||
await resumeRecording(
|
||||
channel,
|
||||
tabId,
|
||||
localData[LocalDataKey.recorderStatus],
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// the extension can't access to the tab
|
||||
});
|
||||
void resumeRecording(tabId);
|
||||
});
|
||||
|
||||
/**
|
||||
* When the current tab is closed, the recording events will be lost because this event is fired after it is closed.
|
||||
* This event listener is just used to make sure the recording status is updated.
|
||||
* When the current tab is closed, and there's no other tab to resume recording, make sure the recording status is updated to SwitchPaused.
|
||||
*/
|
||||
Browser.tabs.onRemoved.addListener((tabId) => {
|
||||
Browser.storage.local
|
||||
.get(LocalDataKey.recorderStatus)
|
||||
.then(async (data) => {
|
||||
const localData = data as LocalData;
|
||||
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
|
||||
const { status, activeTabId, startTimestamp } =
|
||||
localData[LocalDataKey.recorderStatus];
|
||||
if (activeTabId !== tabId || status !== RecorderStatus.RECORDING)
|
||||
void (async () => {
|
||||
if (
|
||||
recorderStatus.activeTabId !== tabId ||
|
||||
recorderStatus.status !== RecorderStatus.RECORDING
|
||||
)
|
||||
return;
|
||||
|
||||
// Update the recording status to make it resumable after users switch to other tabs.
|
||||
const statusData: LocalData[LocalDataKey.recorderStatus] = {
|
||||
Object.assign(recorderStatus, {
|
||||
status: RecorderStatus.PausedSwitch,
|
||||
activeTabId,
|
||||
startTimestamp,
|
||||
activeTabId: -1,
|
||||
pausedTimestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: statusData,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
[LocalDataKey.recorderStatus]: recorderStatus,
|
||||
});
|
||||
})();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -160,3 +278,15 @@ function setDefaultSettings(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateSession(title: string) {
|
||||
const newSession: Session = {
|
||||
id: nanoid(),
|
||||
name: title,
|
||||
tags: [],
|
||||
createTimestamp: Date.now(),
|
||||
modifyTimestamp: Date.now(),
|
||||
recorderVersion: Browser.runtime.getManifest().version_name || 'unknown',
|
||||
};
|
||||
return newSession;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import {
|
||||
type LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
ServiceName,
|
||||
type Session,
|
||||
type RecordStartedMessage,
|
||||
type RecordStoppedMessage,
|
||||
MessageName,
|
||||
type EmitEventMessage,
|
||||
EventName,
|
||||
} from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import { isInCrossOriginIFrame } from '~/utils';
|
||||
@@ -46,8 +44,6 @@ void (() => {
|
||||
})();
|
||||
|
||||
async function initMainPage() {
|
||||
let bufferedEvents: eventWithTime[] = [];
|
||||
let newEvents: eventWithTime[] = [];
|
||||
let startResponseCb: ((response: RecordStartedMessage) => void) | undefined =
|
||||
undefined;
|
||||
channel.provide(ServiceName.StartRecord, async () => {
|
||||
@@ -58,24 +54,6 @@ async function initMainPage() {
|
||||
};
|
||||
});
|
||||
});
|
||||
channel.provide(ServiceName.ResumeRecord, async (params) => {
|
||||
const { events, pausedTimestamp } = params as {
|
||||
events: eventWithTime[];
|
||||
pausedTimestamp: number;
|
||||
};
|
||||
bufferedEvents = events;
|
||||
startRecord();
|
||||
return new Promise((resolve) => {
|
||||
startResponseCb = (response) => {
|
||||
const pausedTime = response.startTimestamp - pausedTimestamp;
|
||||
// Decrease the time spent in the pause state and make them look like a continuous recording.
|
||||
bufferedEvents.forEach((event) => {
|
||||
event.timestamp += pausedTime;
|
||||
});
|
||||
resolve(response);
|
||||
};
|
||||
});
|
||||
});
|
||||
let stopResponseCb: ((response: RecordStoppedMessage) => void) | undefined =
|
||||
undefined;
|
||||
channel.provide(ServiceName.StopRecord, () => {
|
||||
@@ -83,29 +61,7 @@ async function initMainPage() {
|
||||
return new Promise((resolve) => {
|
||||
stopResponseCb = (response: RecordStoppedMessage) => {
|
||||
stopResponseCb = undefined;
|
||||
const newSession = generateSession();
|
||||
response.session = newSession;
|
||||
bufferedEvents = [];
|
||||
newEvents = [];
|
||||
resolve(response);
|
||||
// clear cache
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.bufferedEvents]: [],
|
||||
});
|
||||
};
|
||||
});
|
||||
});
|
||||
channel.provide(ServiceName.PauseRecord, () => {
|
||||
window.postMessage({ message: MessageName.StopRecord });
|
||||
return new Promise((resolve) => {
|
||||
stopResponseCb = (response: RecordStoppedMessage) => {
|
||||
stopResponseCb = undefined;
|
||||
bufferedEvents = [];
|
||||
newEvents = [];
|
||||
resolve(response);
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.bufferedEvents]: response.events,
|
||||
});
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -132,15 +88,14 @@ async function initMainPage() {
|
||||
event.data.message === MessageName.RecordStopped &&
|
||||
stopResponseCb
|
||||
) {
|
||||
const data = event.data as RecordStoppedMessage;
|
||||
// On firefox, the event.data is immutable, so we need to clone it to avoid errors.
|
||||
const newData = {
|
||||
...data,
|
||||
};
|
||||
newData.events = bufferedEvents.concat(data.events);
|
||||
stopResponseCb(newData);
|
||||
const data = { ...(event.data as RecordStoppedMessage) };
|
||||
stopResponseCb(data);
|
||||
} else if (event.data.message === MessageName.EmitEvent)
|
||||
newEvents.push((event.data as EmitEventMessage).event);
|
||||
channel.emit(
|
||||
EventName.ContentScriptEmitEvent,
|
||||
(event.data as EmitEventMessage).event,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -150,17 +105,7 @@ async function initMainPage() {
|
||||
RecorderStatus.RECORDING
|
||||
) {
|
||||
startRecord();
|
||||
bufferedEvents = localData[LocalDataKey.bufferedEvents] || [];
|
||||
}
|
||||
|
||||
// Before unload pages, cache the new events in the local storage.
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!newEvents.length) return;
|
||||
event.preventDefault();
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initCrossOriginIframe() {
|
||||
@@ -193,15 +138,3 @@ function startRecord() {
|
||||
document.documentElement.removeChild(scriptEl);
|
||||
};
|
||||
}
|
||||
|
||||
function generateSession() {
|
||||
const newSession: Session = {
|
||||
id: nanoid(),
|
||||
name: document.title,
|
||||
tags: [],
|
||||
createTimestamp: Date.now(),
|
||||
modifyTimestamp: Date.now(),
|
||||
recorderVersion: Browser.runtime.getManifest().version_name || 'unknown',
|
||||
};
|
||||
return newSession;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,12 @@ import { isInCrossOriginIFrame } from '~/utils';
|
||||
* This script is injected into both main page and cross-origin IFrames through <script> tags.
|
||||
*/
|
||||
|
||||
const events: eventWithTime[] = [];
|
||||
let stopFn: (() => void) | null = null;
|
||||
|
||||
function startRecord(config: recordOptions<eventWithTime>) {
|
||||
events.length = 0;
|
||||
stopFn =
|
||||
record({
|
||||
emit: (event) => {
|
||||
events.push(event);
|
||||
postMessage({
|
||||
message: MessageName.EmitEvent,
|
||||
event,
|
||||
@@ -52,7 +49,6 @@ const messageHandler = (
|
||||
}
|
||||
postMessage({
|
||||
message: MessageName.RecordStopped,
|
||||
events,
|
||||
endTimestamp: Date.now(),
|
||||
});
|
||||
window.removeEventListener('message', messageHandler);
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function Player() {
|
||||
getEvents(sessionId)
|
||||
.then((events) => {
|
||||
if (!playerElRef.current) return;
|
||||
if (playerRef.current) return;
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const rrwebPlayerVersion = manifest.version_name || manifest.version;
|
||||
@@ -50,6 +51,8 @@ export default function Player() {
|
||||
return () => {
|
||||
// eslint-disable-next-line
|
||||
playerRef.current?.pause();
|
||||
// eslint-disable-next-line
|
||||
playerRef.current?.$destroy();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
chakra,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Editable,
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
Flex,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spacer,
|
||||
Table,
|
||||
Thead,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Text,
|
||||
TableContainer,
|
||||
Flex,
|
||||
Checkbox,
|
||||
Button,
|
||||
Spacer,
|
||||
IconButton,
|
||||
Select,
|
||||
Input,
|
||||
Divider,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useEditableControls,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
@@ -28,10 +35,18 @@ import {
|
||||
type PaginationState,
|
||||
} from '@tanstack/react-table';
|
||||
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
|
||||
import { FiEdit3 as EditIcon } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { eventWithTime } from 'rrweb';
|
||||
import { type Session, EventName } from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import { deleteSessions, getAllSessions, downloadSessions } from '~/utils/storage';
|
||||
import {
|
||||
deleteSessions,
|
||||
getAllSessions,
|
||||
downloadSessions,
|
||||
addSession,
|
||||
updateSession,
|
||||
} from '~/utils/storage';
|
||||
import {
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
@@ -43,8 +58,10 @@ const columnHelper = createColumnHelper<Session>();
|
||||
const channel = new Channel();
|
||||
|
||||
export function SessionList() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'createTimestamp',
|
||||
@@ -100,7 +117,58 @@ export function SessionList() {
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
cell: (info) => info.getValue(),
|
||||
cell: (info) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
function EditableControls() {
|
||||
const { isEditing, getEditButtonProps } = useEditableControls();
|
||||
return (
|
||||
isHovered &&
|
||||
!isEditing && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="edit name"
|
||||
size="sm"
|
||||
icon={<EditIcon />}
|
||||
variant="ghost"
|
||||
{...getEditButtonProps()}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
>
|
||||
<Editable
|
||||
defaultValue={info.getValue()}
|
||||
isPreviewFocusable={false}
|
||||
onSubmit={(nextValue) => {
|
||||
const newSession = { ...info.row.original, name: nextValue };
|
||||
setSessions(
|
||||
sessions.map((s) =>
|
||||
s.id === newSession.id ? newSession : s,
|
||||
),
|
||||
);
|
||||
void updateSession(newSession);
|
||||
}}
|
||||
>
|
||||
<EditablePreview cursor="pointer" />
|
||||
<EditableControls />
|
||||
<EditableInput onClick={(e) => e.stopPropagation()} />
|
||||
</Editable>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.createTimestamp, {
|
||||
@@ -114,7 +182,7 @@ export function SessionList() {
|
||||
header: 'RRWEB Version',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
[sessions],
|
||||
);
|
||||
const table = useReactTable<Session>({
|
||||
columns,
|
||||
@@ -145,8 +213,63 @@ export function SessionList() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content) as {
|
||||
session: Session;
|
||||
events: eventWithTime[];
|
||||
};
|
||||
const id = nanoid();
|
||||
data.session.id = id;
|
||||
await addSession(data.session, data.events);
|
||||
toast({
|
||||
title: 'Session imported',
|
||||
description: 'The session was successfully imported.',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
await updateSessions();
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
toast({
|
||||
title: 'Error importing session',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
size="sm"
|
||||
m={4}
|
||||
>
|
||||
Import Session
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</Flex>
|
||||
<TableContainer fontSize="md">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
@@ -318,7 +441,9 @@ export function SessionList() {
|
||||
onClick={() => {
|
||||
const selectedRows = table.getSelectedRowModel().flatRows;
|
||||
if (selectedRows.length === 0) return;
|
||||
void downloadSessions(selectedRows.map((row) => row.original.id));
|
||||
void downloadSessions(
|
||||
selectedRows.map((row) => row.original.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Browser from 'webextension-polyfill';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -10,18 +11,11 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSettings, FiList, FiPause, FiPlay } from 'react-icons/fi';
|
||||
import Channel from '~/utils/channel';
|
||||
import type {
|
||||
LocalData,
|
||||
RecordStartedMessage,
|
||||
RecordStoppedMessage,
|
||||
Session,
|
||||
} from '~/types';
|
||||
import { LocalDataKey, RecorderStatus, ServiceName, EventName } from '~/types';
|
||||
import Browser from 'webextension-polyfill';
|
||||
import { LocalDataKey, RecorderStatus, EventName } from '~/types';
|
||||
import type { LocalData, Session } from '~/types';
|
||||
|
||||
import { CircleButton } from '~/components/CircleButton';
|
||||
import { Timer } from './Timer';
|
||||
import { pauseRecording, resumeRecording } from '~/utils/recording';
|
||||
import { saveSession } from '~/utils/storage';
|
||||
const RECORD_BUTTON_SIZE = 3;
|
||||
|
||||
const channel = new Channel();
|
||||
@@ -33,15 +27,26 @@ export function App() {
|
||||
const [newSession, setNewSession] = useState<Session | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void Browser.storage.local.get(LocalDataKey.recorderStatus).then((data) => {
|
||||
const localData = data as LocalData;
|
||||
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
|
||||
const { status, startTimestamp, pausedTimestamp } =
|
||||
localData[LocalDataKey.recorderStatus];
|
||||
const parseStatusData = (data: LocalData[LocalDataKey.recorderStatus]) => {
|
||||
const { status, startTimestamp, pausedTimestamp } = data;
|
||||
setStatus(status);
|
||||
if (startTimestamp && pausedTimestamp)
|
||||
setStartTime(Date.now() - pausedTimestamp + startTimestamp || 0);
|
||||
setStartTime(Date.now() - pausedTimestamp + startTimestamp);
|
||||
else if (startTimestamp) setStartTime(startTimestamp);
|
||||
};
|
||||
void Browser.storage.local.get(LocalDataKey.recorderStatus).then((data) => {
|
||||
if (!data || !data[LocalDataKey.recorderStatus]) return;
|
||||
parseStatusData((data as LocalData)[LocalDataKey.recorderStatus]);
|
||||
});
|
||||
void Browser.storage.local.onChanged.addListener((changes) => {
|
||||
if (!changes[LocalDataKey.recorderStatus]) return;
|
||||
const data = changes[LocalDataKey.recorderStatus]
|
||||
.newValue as LocalData[LocalDataKey.recorderStatus];
|
||||
parseStatusData(data);
|
||||
if (data.errorMessage) setErrorMessage(data.errorMessage);
|
||||
});
|
||||
channel.on(EventName.SessionUpdated, (data) => {
|
||||
setNewSession((data as { session: Session }).session);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -80,7 +85,7 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
<Flex justify="center" gap="10" mt="5" mb="5">
|
||||
{[RecorderStatus.IDLE, RecorderStatus.RECORDING].includes(status) && (
|
||||
{
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
title={
|
||||
@@ -89,63 +94,9 @@ export function App() {
|
||||
: 'Stop Recording'
|
||||
}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.RECORDING) {
|
||||
// stop recording
|
||||
setErrorMessage('');
|
||||
void channel.getCurrentTabId().then((tabId) => {
|
||||
if (tabId === -1) return;
|
||||
void channel
|
||||
.requestToTab(tabId, ServiceName.StopRecord, {})
|
||||
.then(async (res: RecordStoppedMessage) => {
|
||||
if (!res) return;
|
||||
|
||||
setStatus(RecorderStatus.IDLE);
|
||||
const status: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: RecorderStatus.IDLE,
|
||||
activeTabId: tabId,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: status,
|
||||
});
|
||||
if (res.session) {
|
||||
setNewSession(res.session);
|
||||
await saveSession(res.session, res.events).catch(
|
||||
(e) => {
|
||||
setErrorMessage((e as { message: string }).message);
|
||||
},
|
||||
);
|
||||
channel.emit(EventName.SessionUpdated, {});
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setErrorMessage(error.message);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// start recording
|
||||
void channel.getCurrentTabId().then((tabId) => {
|
||||
if (tabId === -1) return;
|
||||
void channel
|
||||
.requestToTab(tabId, ServiceName.StartRecord, {})
|
||||
.then(async (res: RecordStartedMessage | undefined) => {
|
||||
if (res) {
|
||||
setStatus(RecorderStatus.RECORDING);
|
||||
setStartTime(res.startTimestamp);
|
||||
const status: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: RecorderStatus.RECORDING,
|
||||
activeTabId: tabId,
|
||||
startTimestamp: res.startTimestamp,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: status,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setErrorMessage(error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (status === RecorderStatus.IDLE)
|
||||
void channel.emit(EventName.StartButtonClicked, {});
|
||||
else void channel.emit(EventName.StopButtonClicked, {});
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -156,7 +107,7 @@ export function App() {
|
||||
bgColor="red.500"
|
||||
/>
|
||||
</CircleButton>
|
||||
)}
|
||||
}
|
||||
{status !== RecorderStatus.IDLE && (
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
@@ -167,26 +118,9 @@ export function App() {
|
||||
}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.RECORDING) {
|
||||
void pauseRecording(channel, RecorderStatus.PAUSED).then(
|
||||
(result) => {
|
||||
if (!result) return;
|
||||
setStatus(result?.status.status);
|
||||
},
|
||||
);
|
||||
void channel.emit(EventName.PauseButtonClicked, {});
|
||||
} else {
|
||||
void channel.getCurrentTabId().then((tabId) => {
|
||||
if (tabId === -1) return;
|
||||
resumeRecording(channel, tabId)
|
||||
.then((statusData) => {
|
||||
if (!statusData) return;
|
||||
setStatus(statusData.status);
|
||||
if (statusData.startTimestamp)
|
||||
setStartTime(statusData.startTimestamp);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setErrorMessage(error.message);
|
||||
});
|
||||
});
|
||||
void channel.emit(EventName.ResumeButtonClicked, {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,6 @@ export type Settings = {
|
||||
|
||||
export enum LocalDataKey {
|
||||
recorderStatus = 'recorder_status',
|
||||
bufferedEvents = 'buffered_events',
|
||||
}
|
||||
|
||||
export type LocalData = {
|
||||
@@ -24,8 +23,8 @@ export type LocalData = {
|
||||
startTimestamp?: number;
|
||||
// the timestamp when the recording is paused
|
||||
pausedTimestamp?: number;
|
||||
errorMessage?: string; // error message when recording failed
|
||||
};
|
||||
[LocalDataKey.bufferedEvents]: eventWithTime[];
|
||||
};
|
||||
|
||||
export enum RecorderStatus {
|
||||
@@ -49,13 +48,16 @@ export type Session = {
|
||||
export enum ServiceName {
|
||||
StartRecord = 'start-record',
|
||||
StopRecord = 'stop-record',
|
||||
PauseRecord = 'pause-record',
|
||||
ResumeRecord = 'resume-record',
|
||||
}
|
||||
|
||||
// all event names for channel
|
||||
export enum EventName {
|
||||
SessionUpdated = 'session-updated',
|
||||
ContentScriptEmitEvent = 'content-script-emit-event',
|
||||
StartButtonClicked = 'start-recording-button-clicked',
|
||||
StopButtonClicked = 'stop-recording-button-clicked',
|
||||
PauseButtonClicked = 'pause-recording-button-clicked',
|
||||
ResumeButtonClicked = 'resume-recording-button-clicked',
|
||||
}
|
||||
|
||||
// all message names for postMessage API
|
||||
@@ -75,9 +77,7 @@ export type RecordStartedMessage = {
|
||||
|
||||
export type RecordStoppedMessage = {
|
||||
message: MessageName.RecordStopped;
|
||||
events: eventWithTime[];
|
||||
endTimestamp: number;
|
||||
session?: Session;
|
||||
};
|
||||
|
||||
export type EmitEventMessage = {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export function isFirefox(): boolean {
|
||||
return window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
return (
|
||||
(typeof window !== 'undefined' &&
|
||||
window.navigator?.userAgent.toLowerCase().includes('firefox')) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function isInCrossOriginIFrame(): boolean {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
|
||||
import {
|
||||
type LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
type RecordStartedMessage,
|
||||
type RecordStoppedMessage,
|
||||
ServiceName,
|
||||
} from '~/types';
|
||||
import type Channel from './channel';
|
||||
import { isFirefox } from '.';
|
||||
|
||||
/**
|
||||
* Some commonly used functions for session recording.
|
||||
*/
|
||||
|
||||
// Pause recording.
|
||||
export async function pauseRecording(
|
||||
channel: Channel,
|
||||
newStatus: RecorderStatus,
|
||||
status?: LocalData[LocalDataKey.recorderStatus],
|
||||
) {
|
||||
if (!status)
|
||||
status = (await Browser.storage.local.get(LocalDataKey.recorderStatus))[
|
||||
LocalDataKey.recorderStatus
|
||||
] as LocalData[LocalDataKey.recorderStatus];
|
||||
const { startTimestamp, activeTabId } = status;
|
||||
const stopResponse = (await channel.requestToTab(
|
||||
activeTabId,
|
||||
ServiceName.PauseRecord,
|
||||
{},
|
||||
)) as RecordStoppedMessage;
|
||||
if (!stopResponse) return;
|
||||
const statusData: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: newStatus,
|
||||
activeTabId,
|
||||
startTimestamp,
|
||||
pausedTimestamp: stopResponse.endTimestamp,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: statusData,
|
||||
});
|
||||
return {
|
||||
status: statusData,
|
||||
bufferedEvents: stopResponse.events,
|
||||
};
|
||||
}
|
||||
|
||||
// Resume recording after change to a new tab.
|
||||
export async function resumeRecording(
|
||||
channel: Channel,
|
||||
newTabId: number,
|
||||
status?: LocalData[LocalDataKey.recorderStatus],
|
||||
bufferedEvents?: eventWithTime[],
|
||||
) {
|
||||
if (!status)
|
||||
status = (await Browser.storage.local.get(LocalDataKey.recorderStatus))[
|
||||
LocalDataKey.recorderStatus
|
||||
] as LocalData[LocalDataKey.recorderStatus];
|
||||
if (!bufferedEvents)
|
||||
bufferedEvents = (
|
||||
(await Browser.storage.local.get(
|
||||
LocalDataKey.bufferedEvents,
|
||||
)) as LocalData
|
||||
)[LocalDataKey.bufferedEvents];
|
||||
const { startTimestamp, pausedTimestamp } = status;
|
||||
// On Firefox, the new tab is not communicable immediately after it is created.
|
||||
if (isFirefox()) await new Promise((r) => setTimeout(r, 50));
|
||||
const startResponse = (await channel.requestToTab(
|
||||
newTabId,
|
||||
ServiceName.ResumeRecord,
|
||||
{ events: bufferedEvents, pausedTimestamp },
|
||||
)) as RecordStartedMessage;
|
||||
if (!startResponse) return;
|
||||
const pausedTime = pausedTimestamp
|
||||
? startResponse.startTimestamp - pausedTimestamp
|
||||
: 0;
|
||||
const statusData: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: RecorderStatus.RECORDING,
|
||||
activeTabId: newTabId,
|
||||
startTimestamp:
|
||||
(startTimestamp || bufferedEvents[0].timestamp) + pausedTime,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: statusData,
|
||||
});
|
||||
return statusData;
|
||||
}
|
||||
@@ -44,13 +44,25 @@ export async function getSessionStore() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSession(session: Session, events: eventWithTime[]) {
|
||||
export async function addSession(session: Session, events: eventWithTime[]) {
|
||||
const eventStore = await getEventStore();
|
||||
await eventStore.put(EventStoreName, { id: session.id, events });
|
||||
const store = await getSessionStore();
|
||||
await store.add(SessionStoreName, session);
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
session: Session,
|
||||
events?: eventWithTime[],
|
||||
) {
|
||||
const eventStore = await getEventStore();
|
||||
if (events) {
|
||||
await eventStore.put(EventStoreName, { id: session.id, events });
|
||||
}
|
||||
const store = await getSessionStore();
|
||||
await store.put(SessionStoreName, session);
|
||||
}
|
||||
|
||||
export async function getSession(id: string) {
|
||||
const store = await getSessionStore();
|
||||
return store.get(SessionStoreName, id) as Promise<Session>;
|
||||
|
||||
@@ -81,7 +81,6 @@ export default defineConfig({
|
||||
// A function to generate manifest file dynamically.
|
||||
manifest: () => {
|
||||
const packageJson = readJsonFile('package.json') as PackageJson;
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
type ManifestBase = {
|
||||
common: Record<string, unknown>;
|
||||
chrome: Record<string, unknown>;
|
||||
@@ -93,7 +92,7 @@ export default defineConfig({
|
||||
v3: ManifestBase;
|
||||
};
|
||||
const ManifestVersion =
|
||||
process.env.TARGET_BROWSER === 'chrome' && isProduction ? 'v3' : 'v2';
|
||||
process.env.TARGET_BROWSER === 'chrome' ? 'v3' : 'v2';
|
||||
const BrowserName =
|
||||
process.env.TARGET_BROWSER === 'chrome' ? 'chrome' : 'firefox';
|
||||
const commonManifest = originalManifest.common;
|
||||
|
||||
Reference in New Issue
Block a user