Files
rrweb/packages/web-extension/src/popup/App.tsx
Yun Feng b837600e80 rrweb extension implementation (#1044)
* feat: add rrweb web-extension package

* refactor: make the extension suitable for manifest v3

* update tsconfig.json

* use version_name rather than recorder_version in manifest.json

* update manifest.json

* enable to keep recording after changing tabs

* enable to record between tabs and urls

* fix CI error

* try to fix CI error

* feat: add pause and resume buttons

* feat: add a link to new session after recording

* improve session list

* refactor: migrate session storage from chrome local storage to indexedDB

* feat: add pagination to session list

* fix: multiple recorders are started after pausing and resuming process

* fix: can't stop recording on firefox browser

* update type import of 'eventWithTime'

* fix CI error

* doc: add readme

* Apply suggestions from Justin's code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* refactor: make use of webNavigation API to implement recording consistent during page navigation

* fix firefox compatibility issue and add title to pages

* add mouseleave listener to enhance the recording liability

* fix firefox compatibility issue and improve the experience of recording resume after closing tabs

* update tsconfig

* upgrade vite-plugin-web-extension config to fix some bugs on facebook web page

* update import links

* refactor: cross tab recording mechanism

apply Justin's suggestion

* refactor: slipt util/index.ts into multiple files

* implement cross-origin iframe recording

* fix: regression of issue: ShadowHost can't be a string (issue 941)

* refactor shadow dom recording to make tests cover key code

* Apply formatting changes

* increase the node memory limitation to avoid CI failure

* Create lovely-pears-cross.md

* Apply formatting changes

* Update packages/web-extension/package.json

* Update .changeset/lovely-pears-cross.md

* update change logs

* delete duplicated property

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
2026-04-01 12:00:00 +08:00

250 lines
8.3 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Box,
Flex,
IconButton,
Link,
Spacer,
Stack,
Text,
} from '@chakra-ui/react';
import { FiSettings, FiList, FiPause, FiPlay } from 'react-icons/fi';
import Channel from '~/utils/channel';
import {
LocalData,
LocalDataKey,
RecorderStatus,
ServiceName,
RecordStartedMessage,
RecordStoppedMessage,
Session,
EventName,
} from '~/types';
import Browser from 'webextension-polyfill';
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();
export function App() {
const [status, setStatus] = useState<RecorderStatus>(RecorderStatus.IDLE);
const [errorMessage, setErrorMessage] = useState('');
const [startTime, setStartTime] = useState(0);
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
];
setStatus(status);
if (startTimestamp && pausedTimestamp)
setStartTime(Date.now() - pausedTimestamp + startTimestamp || 0);
else if (startTimestamp) setStartTime(startTimestamp);
});
}, []);
return (
<Flex direction="column" w={300} padding="5%">
<Flex>
<Text fontSize="md" fontWeight="bold">
RRWeb Recorder
</Text>
<Spacer />
<Stack direction="row">
<IconButton
onClick={() => {
void Browser.tabs.create({ url: '/pages/index.html#/' });
}}
size="xs"
icon={<FiList />}
aria-label={'Session List'}
title="Session List"
></IconButton>
<IconButton
onClick={() => {
void Browser.runtime.openOptionsPage();
}}
size="xs"
icon={<FiSettings />}
aria-label={'Settings button'}
title="Settings"
></IconButton>
</Stack>
</Flex>
{status !== RecorderStatus.IDLE && startTime && (
<Timer
startTime={startTime}
ticking={status === RecorderStatus.RECORDING}
/>
)}
<Flex justify="center" gap="10" mt="5" mb="5">
{[RecorderStatus.IDLE, RecorderStatus.RECORDING].includes(status) && (
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.IDLE
? 'Start Recording'
: '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);
});
});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
margin="0"
bgColor="red.500"
/>
</CircleButton>
)}
{status !== RecorderStatus.IDLE && (
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.RECORDING
? 'Pause Recording'
: 'Resume Recording'
}
onClick={() => {
if (status === RecorderStatus.RECORDING) {
void pauseRecording(channel, RecorderStatus.PAUSED).then(
(result) => {
if (!result) return;
setStatus(result?.status.status);
},
);
} 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);
});
});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={9999}
margin="0"
color="gray.600"
>
{[RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
status,
) && (
<FiPlay
style={{
paddingLeft: '0.5rem',
width: '100%',
height: '100%',
}}
/>
)}
{status === RecorderStatus.RECORDING && (
<FiPause
style={{
width: '100%',
height: '100%',
}}
/>
)}
</Box>
</CircleButton>
)}
</Flex>
{newSession && (
<Text>
<Text as="b">New Session: </Text>
<Link
href={Browser.runtime.getURL(
`pages/index.html#/session/${newSession.id}`,
)}
isExternal
>
{newSession.name}
</Link>
</Text>
)}
{errorMessage !== '' && (
<Text color="red.500" fontSize="md">
{errorMessage}
<br />
Maybe refresh your current tab.
</Text>
)}
</Flex>
);
}