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:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 07d43ec4e9
commit 3e5533b568
12 changed files with 433 additions and 382 deletions

View File

@@ -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)
return;
await resumeRecording(
channel,
tabId,
localData[LocalDataKey.recorderStatus],
);
})
.catch(() => {
// the extension can't access to the tab
});
if (
recorderStatus.status !== RecorderStatus.PausedSwitch ||
recorderStatus.activeTabId === tabId
)
return;
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)
return;
// Update the recording status to make it resumable after users switch to other tabs.
const statusData: LocalData[LocalDataKey.recorderStatus] = {
status: RecorderStatus.PausedSwitch,
activeTabId,
startTimestamp,
pausedTimestamp: Date.now(),
};
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: statusData,
});
})
.catch((err) => {
console.error(err);
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.
Object.assign(recorderStatus, {
status: RecorderStatus.PausedSwitch,
activeTabId: -1,
pausedTimestamp: Date.now(),
});
await Browser.storage.local.set({
[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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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

View File

@@ -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, {});
}
}}
>

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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;