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>
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
---
|
||||
---
|
||||
5
.changeset/lovely-pears-cross.md
Normal file
5
.changeset/lovely-pears-cross.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@rrweb/web-extension': patch
|
||||
---
|
||||
|
||||
Add rrweb browser extension
|
||||
@@ -6,4 +6,4 @@
|
||||
'rrweb-snapshot': patch
|
||||
---
|
||||
|
||||
- [`fe69bd6`](https://github.com/rrweb-io/rrweb/commit/fe69bd6456cead304bfc77cf72c9db0f8c030842) [#1087](https://github.com/rrweb-io/rrweb/pull/1087) Thanks [@YunFeng0817](https://github.com/YunFeng0817)! - Refactor all suffix of bundled scripts with commonjs module from 'js' to cjs.
|
||||
Refactor all suffix of bundled scripts with commonjs module from 'js' to cjs [#1087](https://github.com/rrweb-io/rrweb/pull/1087).
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
---
|
||||
---
|
||||
@@ -3,4 +3,4 @@
|
||||
'rrweb': patch
|
||||
---
|
||||
|
||||
- [`4ee86fe`](https://github.com/rrweb-io/rrweb/commit/4ee86fe66d3e1fe7071f9c8764d82a6fa5c71d57) [#1091](https://github.com/rrweb-io/rrweb/pull/1091) Thanks [@YunFeng0817](https://github.com/YunFeng0817)! - Fix: improve rrdom robustness.
|
||||
Fix: improve rrdom robustness [#1091](https://github.com/rrweb-io/rrweb/pull/1091).
|
||||
|
||||
2
.github/workflows/ci-cd.yml
vendored
2
.github/workflows/ci-cd.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Build Project
|
||||
run: yarn build:all
|
||||
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
|
||||
|
||||
- name: Check types
|
||||
run: yarn turbo run check-types
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: yarn run release
|
||||
publish: NODE_OPTIONS='--max-old-space-size=4096' yarn run release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
2
.github/workflows/style-check.yml
vendored
2
.github/workflows/style-check.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
- name: Build Packages
|
||||
run: yarn build:all
|
||||
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
|
||||
- name: Eslint Check
|
||||
run: yarn turbo run lint
|
||||
- name: Save Code Linting Report JSON
|
||||
|
||||
7
.vscode/rrweb-monorepo.code-workspace
vendored
7
.vscode/rrweb-monorepo.code-workspace
vendored
@@ -25,9 +25,10 @@
|
||||
"path": "../packages/rrweb-snapshot"
|
||||
},
|
||||
{
|
||||
"name": "@rrweb/types",
|
||||
"path": "../packages/types"
|
||||
}
|
||||
"name": "web-extension (package)",
|
||||
"path": "../packages/web-extension"
|
||||
},
|
||||
{ "name": "@rrweb/types", "path": "../packages/types" }
|
||||
],
|
||||
"settings": {
|
||||
"jest.disabledWorkspaceFolders": [
|
||||
|
||||
32
packages/web-extension/README.md
Normal file
32
packages/web-extension/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
<p align="center">
|
||||
<img width="100px" height="100px" src="https://www.rrweb.io/favicon.png">
|
||||
</p>
|
||||
|
||||
# rrweb extension
|
||||
|
||||
The package web-extension provides a browser extension for recording and replaying web pages.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# build for chrome
|
||||
yarn build:chrome
|
||||
|
||||
# build for firefox
|
||||
yarn build:firefox
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# start a development chrome browser
|
||||
yarn dev:chrome
|
||||
# start a development firefox browser
|
||||
yarn dev:firefox
|
||||
```
|
||||
47
packages/web-extension/package.json
Normal file
47
packages/web-extension/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@rrweb/web-extension",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"description": "The web extension of rrweb which helps to run rrweb on any website out of box",
|
||||
"author": "rrweb-io",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev:chrome": "cross-env TARGET_BROWSER=chrome vite dev",
|
||||
"dev:firefox": "cross-env TARGET_BROWSER=firefox vite dev",
|
||||
"build:chrome": "cross-env TARGET_BROWSER=chrome vite build",
|
||||
"build:firefox": "cross-env TARGET_BROWSER=firefox vite build",
|
||||
"pack:chrome": "cross-env TARGET_BROWSER=chrome ZIP=true vite build",
|
||||
"pack:firefox": "cross-env TARGET_BROWSER=firefox ZIP=true vite build",
|
||||
"check-types": "tsc -noEmit",
|
||||
"prepublish": "npm run pack:chrome && npm run pack:firefox"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rrweb/types": "^2.0.0-alpha.4",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/webextension-polyfill": "^0.9.1",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"type-fest": "^2.19.0",
|
||||
"typescript": "^4.7.3",
|
||||
"vite": "^3.1.8",
|
||||
"vite-plugin-web-extension": "^1.4.5",
|
||||
"vite-plugin-zip": "^1.0.1",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.3.4",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/styled": "^11.10.4",
|
||||
"@tanstack/react-table": "^8.5.22",
|
||||
"framer-motion": "^7.3.6",
|
||||
"idb": "^7.1.1",
|
||||
"mitt": "^3.0.0",
|
||||
"nanoid": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-router-dom": "^6.4.1",
|
||||
"rrweb": "^2.0.0-alpha.4",
|
||||
"rrweb-player": "^1.0.0-alpha.4"
|
||||
}
|
||||
}
|
||||
BIN
packages/web-extension/src/assets/icon128.png
Normal file
BIN
packages/web-extension/src/assets/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
BIN
packages/web-extension/src/assets/icon16.png
Normal file
BIN
packages/web-extension/src/assets/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
packages/web-extension/src/assets/icon48.png
Normal file
BIN
packages/web-extension/src/assets/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
162
packages/web-extension/src/background/index.ts
Normal file
162
packages/web-extension/src/background/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import {
|
||||
LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
Settings,
|
||||
SyncData,
|
||||
SyncDataKey,
|
||||
} from '~/types';
|
||||
import { pauseRecording, resumeRecording } from '~/utils/recording';
|
||||
|
||||
const channel = new Channel();
|
||||
|
||||
void (async () => {
|
||||
// assign default value to settings of this extension
|
||||
const result =
|
||||
((await Browser.storage.sync.get(SyncDataKey.settings)) as SyncData) ||
|
||||
undefined;
|
||||
const defaultSettings: Settings = {};
|
||||
let settings = defaultSettings;
|
||||
if (result && result.settings) {
|
||||
setDefaultSettings(result.settings, defaultSettings);
|
||||
settings = result.settings;
|
||||
}
|
||||
await Browser.storage.sync.set({
|
||||
settings,
|
||||
} as SyncData);
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Update existed settings with new settings.
|
||||
* Set new setting values if these properties don't exist in older versions.
|
||||
*/
|
||||
function setDefaultSettings(
|
||||
existedSettings: Record<string, unknown>,
|
||||
newSettings: Record<string, unknown>,
|
||||
) {
|
||||
for (const i in newSettings) {
|
||||
// settings[i] contains key-value settings
|
||||
if (
|
||||
typeof newSettings[i] === 'object' &&
|
||||
!(newSettings[i] instanceof Array) &&
|
||||
Object.keys(newSettings[i] as Record<string, unknown>).length > 0
|
||||
) {
|
||||
if (existedSettings[i]) {
|
||||
setDefaultSettings(
|
||||
existedSettings[i] as Record<string, unknown>,
|
||||
newSettings[i] as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
// settings[i] contains several setting items but these have not been set before
|
||||
existedSettings[i] = newSettings[i];
|
||||
}
|
||||
} else if (existedSettings[i] === undefined) {
|
||||
// settings[i] is a single setting item and it has not been set before
|
||||
existedSettings[i] = newSettings[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/web-extension/src/components/CircleButton.tsx
Normal file
33
packages/web-extension/src/components/CircleButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button, ButtonProps } from '@chakra-ui/react';
|
||||
|
||||
interface CircleButtonProps extends ButtonProps {
|
||||
diameter: number;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function CircleButton({
|
||||
diameter,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
...rest
|
||||
}: CircleButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
w={`${diameter}rem`}
|
||||
h={`${diameter}rem`}
|
||||
padding={`${diameter / 2}rem`}
|
||||
borderRadius={9999}
|
||||
textAlign="center"
|
||||
bgColor="gray.100"
|
||||
boxSizing="content-box"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
290
packages/web-extension/src/components/SidebarWithHeader.tsx
Normal file
290
packages/web-extension/src/components/SidebarWithHeader.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Box,
|
||||
CloseButton,
|
||||
Flex,
|
||||
HStack,
|
||||
Icon,
|
||||
Image,
|
||||
useColorModeValue,
|
||||
Link,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
useDisclosure,
|
||||
BoxProps,
|
||||
FlexProps,
|
||||
Heading,
|
||||
Stack,
|
||||
Text,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiChevronRight, FiMenu } from 'react-icons/fi';
|
||||
import type { IconType } from 'react-icons';
|
||||
import Browser from 'webextension-polyfill';
|
||||
|
||||
export interface SideBarItem {
|
||||
label: string;
|
||||
icon: IconType;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface HeadBarItem {
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
children?: Array<HeadBarItem>;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export default function SidebarWithHeader({
|
||||
children,
|
||||
title,
|
||||
headBarItems,
|
||||
sideBarItems,
|
||||
}: {
|
||||
title?: string;
|
||||
sideBarItems: SideBarItem[];
|
||||
headBarItems: SideBarItem[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
<SidebarContent
|
||||
sideBarItems={sideBarItems}
|
||||
onClose={() => onClose}
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
title={title}
|
||||
/>
|
||||
<Drawer
|
||||
autoFocus={false}
|
||||
isOpen={isOpen}
|
||||
placement="left"
|
||||
onClose={onClose}
|
||||
returnFocusOnClose={false}
|
||||
onOverlayClick={onClose}
|
||||
size="full"
|
||||
>
|
||||
<DrawerContent>
|
||||
<SidebarContent
|
||||
onClose={onClose}
|
||||
sideBarItems={sideBarItems}
|
||||
title={title}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<MobileNav onOpen={onOpen}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
display={{ base: 'none', md: 'flex' }}
|
||||
ml={10}
|
||||
>
|
||||
<DesktopNav headBarItems={headBarItems} />
|
||||
</Flex>
|
||||
</MobileNav>
|
||||
<Box ml={{ base: 0, md: 60 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps extends BoxProps {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
sideBarItems: SideBarItem[];
|
||||
}
|
||||
|
||||
const SidebarContent = ({
|
||||
onClose,
|
||||
sideBarItems,
|
||||
title,
|
||||
...rest
|
||||
}: SidebarProps) => {
|
||||
return (
|
||||
<Box
|
||||
transition="3s ease"
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderRight="1px"
|
||||
borderRightColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
w={{ base: 'full', md: 60 }}
|
||||
pos="fixed"
|
||||
h="full"
|
||||
{...rest}
|
||||
>
|
||||
<Flex h="20" alignItems="center" mx="8" justify="flex-start" gap="3">
|
||||
<Link href="https://github.com/rrweb-io/rrweb" target="_blank">
|
||||
<Image
|
||||
borderRadius="md"
|
||||
boxSize="2rem"
|
||||
src={Browser.runtime.getURL('assets/icon128.png')}
|
||||
alt="RRWeb Logo"
|
||||
/>
|
||||
</Link>
|
||||
{title && (
|
||||
<Heading as="h4" size="md">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
<CloseButton display={{ base: 'flex', md: 'none' }} onClick={onClose} />
|
||||
</Flex>
|
||||
{sideBarItems.map((link) => (
|
||||
<NavItem key={link.label} icon={link.icon} href={link.href}>
|
||||
{link.label}
|
||||
</NavItem>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavItemProps extends FlexProps {
|
||||
icon: IconType;
|
||||
href: string;
|
||||
children: string;
|
||||
}
|
||||
const NavItem = ({ icon, href, children, ...rest }: NavItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{ textDecoration: 'none' }}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
fontSize="lg"
|
||||
fontWeight={500}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
p="4"
|
||||
mx="4"
|
||||
borderRadius="lg"
|
||||
role="group"
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
bg: 'gray.200',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<>
|
||||
{icon && <Icon mr="4" fontSize="16" as={icon} />}
|
||||
{children}
|
||||
</>
|
||||
</Flex>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface MobileProps extends FlexProps {
|
||||
onOpen: () => void;
|
||||
}
|
||||
const MobileNav = ({ onOpen, ...rest }: MobileProps) => {
|
||||
return (
|
||||
<Flex
|
||||
ml={{ base: 0, md: 60 }}
|
||||
px={{ base: 4, md: 4 }}
|
||||
height="20"
|
||||
alignItems="center"
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
justifyContent={{ base: 'space-between', md: 'flex-start' }}
|
||||
{...rest}
|
||||
>
|
||||
<IconButton
|
||||
display={{ base: 'flex', md: 'none' }}
|
||||
onClick={onOpen}
|
||||
variant="outline"
|
||||
aria-label="open menu"
|
||||
icon={<FiMenu />}
|
||||
/>
|
||||
|
||||
<HStack spacing={{ base: '0', md: '6' }}>
|
||||
{rest.children && rest.children}
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ headBarItems }: { headBarItems: HeadBarItem[] }) => {
|
||||
const linkColor = useColorModeValue('gray.600', 'gray.200');
|
||||
const linkHoverColor = useColorModeValue('gray.800', 'white');
|
||||
const popoverContentBgColor = useColorModeValue('white', 'gray.800');
|
||||
|
||||
return (
|
||||
<Stack direction={'row'} spacing={4}>
|
||||
{headBarItems.map((navItem) => (
|
||||
<Box key={navItem.label}>
|
||||
<Popover trigger={'hover'} placement={'bottom-start'}>
|
||||
<PopoverTrigger>
|
||||
<Link
|
||||
p={2}
|
||||
href={navItem.href ?? '#'}
|
||||
fontSize={'sm'}
|
||||
fontWeight={500}
|
||||
color={linkColor}
|
||||
_hover={{
|
||||
textDecoration: 'none',
|
||||
color: linkHoverColor,
|
||||
}}
|
||||
>
|
||||
{navItem.label}
|
||||
</Link>
|
||||
</PopoverTrigger>
|
||||
|
||||
{navItem.children && (
|
||||
<PopoverContent
|
||||
border={0}
|
||||
boxShadow={'xl'}
|
||||
bg={popoverContentBgColor}
|
||||
p={4}
|
||||
rounded={'xl'}
|
||||
minW={'sm'}
|
||||
>
|
||||
<Stack>
|
||||
{navItem.children.map((child) => (
|
||||
<DesktopSubNav key={child.label} {...child} />
|
||||
))}
|
||||
</Stack>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopSubNav = ({ label, href, subLabel }: HeadBarItem) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
role={'group'}
|
||||
display={'block'}
|
||||
p={2}
|
||||
rounded={'md'}
|
||||
_hover={{ bg: useColorModeValue('pink.50', 'gray.900') }}
|
||||
>
|
||||
<Stack direction={'row'} align={'center'}>
|
||||
<Box>
|
||||
<Text
|
||||
transition={'all .3s ease'}
|
||||
_groupHover={{ color: 'pink.400' }}
|
||||
fontWeight={500}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize={'sm'}>{subLabel}</Text>
|
||||
</Box>
|
||||
<Flex
|
||||
transition={'all .3s ease'}
|
||||
transform={'translateX(-10px)'}
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: '100%', transform: 'translateX(0)' }}
|
||||
justify={'flex-end'}
|
||||
align={'center'}
|
||||
flex={1}
|
||||
>
|
||||
<Icon color={'pink.400'} w={5} h={5} as={FiChevronRight} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
207
packages/web-extension/src/content/index.ts
Normal file
207
packages/web-extension/src/content/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import Browser, { Storage } from 'webextension-polyfill';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import {
|
||||
LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
ServiceName,
|
||||
Session,
|
||||
RecordStartedMessage,
|
||||
RecordStoppedMessage,
|
||||
MessageName,
|
||||
EmitEventMessage,
|
||||
} from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import { isInCrossOriginIFrame } from '~/utils';
|
||||
|
||||
const channel = new Channel();
|
||||
|
||||
void (() => {
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(
|
||||
event: MessageEvent<{
|
||||
message: MessageName;
|
||||
}>,
|
||||
) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data.message === MessageName.RecordScriptReady)
|
||||
window.postMessage(
|
||||
{
|
||||
message: MessageName.StartRecord,
|
||||
config: {
|
||||
recordCrossOriginIframes: true,
|
||||
},
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (isInCrossOriginIFrame()) {
|
||||
void initCrossOriginIframe();
|
||||
} else {
|
||||
void initMainPage();
|
||||
}
|
||||
})();
|
||||
|
||||
async function initMainPage() {
|
||||
let bufferedEvents: eventWithTime[] = [];
|
||||
let newEvents: eventWithTime[] = [];
|
||||
let startResponseCb: ((response: RecordStartedMessage) => void) | undefined =
|
||||
undefined;
|
||||
channel.provide(ServiceName.StartRecord, async () => {
|
||||
startRecord();
|
||||
return new Promise((resolve) => {
|
||||
startResponseCb = (response) => {
|
||||
resolve(response);
|
||||
};
|
||||
});
|
||||
});
|
||||
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, () => {
|
||||
window.postMessage({ message: MessageName.StopRecord });
|
||||
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,
|
||||
});
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(
|
||||
event: MessageEvent<
|
||||
| RecordStartedMessage
|
||||
| RecordStoppedMessage
|
||||
| EmitEventMessage
|
||||
| {
|
||||
message: MessageName;
|
||||
}
|
||||
>,
|
||||
) => {
|
||||
if (event.source !== window) return;
|
||||
else if (
|
||||
event.data.message === MessageName.RecordStarted &&
|
||||
startResponseCb
|
||||
)
|
||||
startResponseCb(event.data as RecordStartedMessage);
|
||||
else if (
|
||||
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);
|
||||
} else if (event.data.message === MessageName.EmitEvent)
|
||||
newEvents.push((event.data as EmitEventMessage).event);
|
||||
},
|
||||
);
|
||||
|
||||
const localData = (await Browser.storage.local.get()) as LocalData;
|
||||
if (
|
||||
localData?.[LocalDataKey.recorderStatus]?.status ===
|
||||
RecorderStatus.RECORDING
|
||||
) {
|
||||
startRecord();
|
||||
bufferedEvents = localData[LocalDataKey.bufferedEvents] || [];
|
||||
}
|
||||
|
||||
// Before unload pages, cache the new events in the local storage.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
void Browser.storage.local.set({
|
||||
[LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initCrossOriginIframe() {
|
||||
Browser.storage.local.onChanged.addListener((change) => {
|
||||
if (change[LocalDataKey.recorderStatus]) {
|
||||
const statusChange = change[
|
||||
LocalDataKey.recorderStatus
|
||||
] as Storage.StorageChange;
|
||||
const newStatus =
|
||||
statusChange.newValue as LocalData[LocalDataKey.recorderStatus];
|
||||
if (newStatus.status === RecorderStatus.RECORDING) startRecord();
|
||||
else
|
||||
window.postMessage(
|
||||
{ message: MessageName.StopRecord },
|
||||
location.origin,
|
||||
);
|
||||
}
|
||||
});
|
||||
const localData = (await Browser.storage.local.get()) as LocalData;
|
||||
if (
|
||||
localData?.[LocalDataKey.recorderStatus]?.status ===
|
||||
RecorderStatus.RECORDING
|
||||
)
|
||||
startRecord();
|
||||
}
|
||||
|
||||
function startRecord() {
|
||||
const scriptEl = document.createElement('script');
|
||||
scriptEl.src = Browser.runtime.getURL('content/inject.js');
|
||||
document.documentElement.appendChild(scriptEl);
|
||||
scriptEl.onload = () => {
|
||||
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;
|
||||
}
|
||||
72
packages/web-extension/src/content/inject.ts
Normal file
72
packages/web-extension/src/content/inject.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { record } from 'rrweb';
|
||||
import type { recordOptions } from 'rrweb/typings/types';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import { MessageName, RecordStartedMessage } from '~/types';
|
||||
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,
|
||||
});
|
||||
},
|
||||
...config,
|
||||
}) || null;
|
||||
postMessage({
|
||||
message: MessageName.RecordStarted,
|
||||
startTimestamp: Date.now(),
|
||||
} as RecordStartedMessage);
|
||||
}
|
||||
|
||||
const messageHandler = (
|
||||
event: MessageEvent<{
|
||||
message: MessageName;
|
||||
config?: recordOptions<eventWithTime>;
|
||||
}>,
|
||||
) => {
|
||||
if (event.source !== window) return;
|
||||
const data = event.data;
|
||||
const eventHandler = {
|
||||
[MessageName.StartRecord]: () => {
|
||||
startRecord(data.config || {});
|
||||
},
|
||||
[MessageName.StopRecord]: () => {
|
||||
if (stopFn) stopFn();
|
||||
postMessage({
|
||||
message: MessageName.RecordStopped,
|
||||
events,
|
||||
endTimestamp: Date.now(),
|
||||
});
|
||||
window.removeEventListener('message', messageHandler);
|
||||
},
|
||||
} as Record<MessageName, () => void>;
|
||||
if (eventHandler[data.message]) eventHandler[data.message]();
|
||||
};
|
||||
|
||||
/**
|
||||
* Only post message in the main page.
|
||||
*/
|
||||
function postMessage(message: unknown) {
|
||||
if (!isInCrossOriginIFrame()) window.postMessage(message, location.origin);
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
message: MessageName.RecordScriptReady,
|
||||
},
|
||||
location.origin,
|
||||
);
|
||||
74
packages/web-extension/src/manifest.json
Normal file
74
packages/web-extension/src/manifest.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"common": {
|
||||
"name": "rrweb",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content/index.ts"],
|
||||
"all_frames": true,
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "assets/icon16.png",
|
||||
"48": "assets/icon48.png",
|
||||
"128": "assets/icon128.png"
|
||||
},
|
||||
"permissions": ["activeTab", "tabs", "storage", "unlimitedStorage"]
|
||||
},
|
||||
"v2": {
|
||||
"common": {
|
||||
"manifest_version": 2,
|
||||
"background": {
|
||||
"persistent": false,
|
||||
"scripts": ["background/index.ts"]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "rrweb extension",
|
||||
"default_popup": "popup/popup.html",
|
||||
"browser_style": false
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options/index.html",
|
||||
"open_in_tab": true,
|
||||
"chrome_style": false
|
||||
},
|
||||
"web_accessible_resources": ["**/*.js"]
|
||||
},
|
||||
"chrome": {},
|
||||
"firefox": {
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "rrweb@rrweb.io",
|
||||
"strict_min_version": "42.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v3": {
|
||||
"common": {
|
||||
"manifest_version": 3,
|
||||
"background": {
|
||||
"service_worker": "background/index.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "rrweb extension",
|
||||
"default_popup": "popup/popup.html",
|
||||
"browser_style": false
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options/index.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["**/*.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chrome": {},
|
||||
"firefox": {}
|
||||
}
|
||||
}
|
||||
31
packages/web-extension/src/options/App.tsx
Normal file
31
packages/web-extension/src/options/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import SidebarWithHeader from '~/components/SidebarWithHeader';
|
||||
import { FiList, FiSettings } from 'react-icons/fi';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SidebarWithHeader
|
||||
title="Settings"
|
||||
headBarItems={[
|
||||
{
|
||||
label: 'Sessions',
|
||||
icon: FiList,
|
||||
href: '/pages/index.html#',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
href: '#',
|
||||
},
|
||||
]}
|
||||
sideBarItems={[]}
|
||||
>
|
||||
<Box p="10">
|
||||
<Routes>
|
||||
<Route path="/" element={<></>} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</SidebarWithHeader>
|
||||
);
|
||||
}
|
||||
9
packages/web-extension/src/options/index.html
Normal file
9
packages/web-extension/src/options/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<title>rrweb settings</title>
|
||||
<html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</html>
|
||||
22
packages/web-extension/src/options/index.tsx
Normal file
22
packages/web-extension/src/options/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/*',
|
||||
element: <App />,
|
||||
},
|
||||
]);
|
||||
|
||||
rootElement &&
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
37
packages/web-extension/src/pages/App.tsx
Normal file
37
packages/web-extension/src/pages/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import SidebarWithHeader from '~/components/SidebarWithHeader';
|
||||
import { SessionList } from './SessionList';
|
||||
import { FiList, FiSettings } from 'react-icons/fi';
|
||||
import Player from './Player';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SidebarWithHeader
|
||||
title="Sessions"
|
||||
headBarItems={[
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
href: '/options/index.html#',
|
||||
},
|
||||
{
|
||||
label: 'Sessions',
|
||||
icon: FiList,
|
||||
href: '#',
|
||||
},
|
||||
]}
|
||||
sideBarItems={[
|
||||
{
|
||||
label: 'List',
|
||||
icon: FiList,
|
||||
href: `#`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<SessionList />} />
|
||||
<Route path="session/:sessionId" element={<Player />} />
|
||||
</Routes>
|
||||
</SidebarWithHeader>
|
||||
);
|
||||
}
|
||||
68
packages/web-extension/src/pages/Player.tsx
Normal file
68
packages/web-extension/src/pages/Player.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Replayer from 'rrweb-player';
|
||||
import {
|
||||
Box,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { getEvents, getSession } from '~/utils/storage';
|
||||
|
||||
export default function Player() {
|
||||
const playerElRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Replayer | null>(null);
|
||||
const { sessionId } = useParams();
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
getSession(sessionId)
|
||||
.then((session) => {
|
||||
setSessionName(session.name);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
getEvents(sessionId)
|
||||
.then((events) => {
|
||||
if (!playerElRef.current || !sessionId) return;
|
||||
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.href =
|
||||
'https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css';
|
||||
linkEl.rel = 'stylesheet';
|
||||
document.head.appendChild(linkEl);
|
||||
playerRef.current = new Replayer({
|
||||
target: playerElRef.current as HTMLElement,
|
||||
props: {
|
||||
events,
|
||||
autoPlay: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
return () => {
|
||||
playerRef.current?.pause();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb mb={5} fontSize="md">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Sessions</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink>{sessionName}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Center>
|
||||
<Box ref={playerElRef}></Box>
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
packages/web-extension/src/pages/SessionList.tsx
Normal file
318
packages/web-extension/src/pages/SessionList.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
chakra,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Text,
|
||||
TableContainer,
|
||||
Flex,
|
||||
Checkbox,
|
||||
Button,
|
||||
Spacer,
|
||||
IconButton,
|
||||
Select,
|
||||
Input,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
} from '@tanstack/react-table';
|
||||
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Session, EventName } from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import { deleteSessions, getAllSessions } from '~/utils/storage';
|
||||
import {
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiChevronsLeft,
|
||||
FiChevronsRight,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const columnHelper = createColumnHelper<Session>();
|
||||
const channel = new Channel();
|
||||
|
||||
export function SessionList() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'createTimestamp',
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const fetchDataOptions = {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
|
||||
return {
|
||||
rows: sessions.slice(
|
||||
options.pageIndex * options.pageSize,
|
||||
(options.pageIndex + 1) * options.pageSize,
|
||||
),
|
||||
pageCount: Math.ceil(sessions.length / options.pageSize),
|
||||
};
|
||||
};
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
isChecked={table.getIsAllRowsSelected()}
|
||||
isIndeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
isChecked={row.getIsSelected()}
|
||||
isIndeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.createTimestamp, {
|
||||
id: 'createTimestamp',
|
||||
cell: (info) => new Date(info.getValue()).toLocaleString(),
|
||||
header: 'Created Time',
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.recorderVersion, {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'RRWEB Version',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const table = useReactTable<Session>({
|
||||
columns,
|
||||
data: fetchData(fetchDataOptions).rows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
manualPagination: true,
|
||||
pageCount: fetchData(fetchDataOptions).pageCount,
|
||||
});
|
||||
|
||||
const updateSessions = async () => {
|
||||
const sessions = await getAllSessions();
|
||||
setSessions(sessions);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void updateSessions();
|
||||
channel.on(EventName.SessionUpdated, () => {
|
||||
void updateSessions();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer fontSize="md">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
isNumeric={meta?.isNumeric}
|
||||
verticalAlign="center"
|
||||
userSelect="none"
|
||||
>
|
||||
<Flex align="center">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<chakra.span pl={4}>
|
||||
{{
|
||||
asc: (
|
||||
<VscTriangleUp aria-label="sorted ascending" />
|
||||
),
|
||||
desc: (
|
||||
<VscTriangleDown aria-label="sorted descending" />
|
||||
),
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</chakra.span>
|
||||
</Flex>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Tr key={row.id} _hover={{ cursor: 'pointer' }}>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Td
|
||||
key={cell.id}
|
||||
isNumeric={meta?.isNumeric}
|
||||
onClick={() => {
|
||||
if (index !== 0)
|
||||
navigate(`/session/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex mt={4}>
|
||||
<Flex gap={16} align="center" ml={4}>
|
||||
<Flex gap={1}>
|
||||
<IconButton
|
||||
aria-label={'Goto 1st Page'}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FiChevronsLeft />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={'Goto Previous Page'}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<FiChevronLeft />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
<Flex gap={1} fontSize="md">
|
||||
<Text>Page</Text>
|
||||
<Text as="b" w={12}>
|
||||
{`${
|
||||
table.getState().pagination.pageIndex + 1
|
||||
} of ${table.getPageCount()}`}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Divider orientation="vertical" />
|
||||
<Flex gap={1} justify="center" align="center" fontSize="md">
|
||||
<Text w={28}>Go to page:</Text>
|
||||
<Input
|
||||
w={20}
|
||||
size="md"
|
||||
type="number"
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
onChange={(e) => {
|
||||
const page = e.target.value ? Number(e.target.value) - 1 : 0;
|
||||
table.setPageIndex(page);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={1}>
|
||||
<IconButton
|
||||
aria-label={'Goto Next Page'}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FiChevronRight />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={'Goto last Page'}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<FiChevronsRight />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Spacer />
|
||||
<Flex gap={8} align="center" mr={4}>
|
||||
<Select
|
||||
variant="outline"
|
||||
size="md"
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
table.setPageSize(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Show {pageSize} items
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<Button
|
||||
mr={4}
|
||||
size="md"
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
if (table.getSelectedRowModel().flatRows.length === 0) return;
|
||||
const ids = table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original.id);
|
||||
void deleteSessions(ids).then(() => {
|
||||
setRowSelection({});
|
||||
void updateSessions();
|
||||
channel.emit(EventName.SessionUpdated, {});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
packages/web-extension/src/pages/index.html
Normal file
9
packages/web-extension/src/pages/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<title>rrweb</title>
|
||||
<html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</html>
|
||||
22
packages/web-extension/src/pages/index.tsx
Normal file
22
packages/web-extension/src/pages/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/*',
|
||||
element: <App />,
|
||||
},
|
||||
]);
|
||||
|
||||
rootElement &&
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
249
packages/web-extension/src/popup/App.tsx
Normal file
249
packages/web-extension/src/popup/App.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
25
packages/web-extension/src/popup/Timer.tsx
Normal file
25
packages/web-extension/src/popup/Timer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stat, StatNumber } from '@chakra-ui/react';
|
||||
import { formatTime } from '~/utils';
|
||||
|
||||
export function Timer({
|
||||
startTime,
|
||||
ticking,
|
||||
}: {
|
||||
startTime: number;
|
||||
ticking: boolean;
|
||||
}) {
|
||||
const [time, setTime] = useState(Date.now() - startTime);
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const interval = setInterval(() => {
|
||||
setTime(Date.now() - startTime);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, ticking]);
|
||||
return (
|
||||
<Stat textAlign="center" mt={4}>
|
||||
<StatNumber fontSize="3xl">{formatTime(time)}</StatNumber>
|
||||
</Stat>
|
||||
);
|
||||
}
|
||||
15
packages/web-extension/src/popup/index.tsx
Normal file
15
packages/web-extension/src/popup/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
rootElement &&
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
8
packages/web-extension/src/popup/popup.html
Normal file
8
packages/web-extension/src/popup/popup.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</html>
|
||||
86
packages/web-extension/src/types.ts
Normal file
86
packages/web-extension/src/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
|
||||
export enum SyncDataKey {
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
export type SyncData = {
|
||||
[SyncDataKey.settings]: Settings;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
//
|
||||
};
|
||||
|
||||
export enum LocalDataKey {
|
||||
recorderStatus = 'recorder_status',
|
||||
bufferedEvents = 'buffered_events',
|
||||
}
|
||||
|
||||
export type LocalData = {
|
||||
[LocalDataKey.recorderStatus]: {
|
||||
status: RecorderStatus;
|
||||
activeTabId: number;
|
||||
startTimestamp?: number;
|
||||
// the timestamp when the recording is paused
|
||||
pausedTimestamp?: number;
|
||||
};
|
||||
[LocalDataKey.bufferedEvents]: eventWithTime[];
|
||||
};
|
||||
|
||||
export enum RecorderStatus {
|
||||
IDLE = 'IDLE',
|
||||
RECORDING = 'RECORDING',
|
||||
PAUSED = 'PAUSED',
|
||||
// when user change the tab, the recorder will be paused during the tab change
|
||||
PausedSwitch = 'PAUSED_SWITCH',
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
createTimestamp: number;
|
||||
modifyTimestamp: number;
|
||||
recorderVersion: string;
|
||||
};
|
||||
|
||||
// all service names for channel
|
||||
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',
|
||||
}
|
||||
|
||||
// all message names for postMessage API
|
||||
export enum MessageName {
|
||||
RecordScriptReady = 'rrweb-extension-record-script-ready',
|
||||
StartRecord = 'rrweb-extension-start-record',
|
||||
RecordStarted = 'rrweb-extension-record-started',
|
||||
StopRecord = 'rrweb-extension-stop-record',
|
||||
RecordStopped = 'rrweb-extension-record-stopped',
|
||||
EmitEvent = 'rrweb-extension-emit-event',
|
||||
}
|
||||
|
||||
export type RecordStartedMessage = {
|
||||
message: MessageName.RecordStarted;
|
||||
startTimestamp: number;
|
||||
};
|
||||
|
||||
export type RecordStoppedMessage = {
|
||||
message: MessageName.RecordStopped;
|
||||
events: eventWithTime[];
|
||||
endTimestamp: number;
|
||||
session?: Session;
|
||||
};
|
||||
|
||||
export type EmitEventMessage = {
|
||||
message: MessageName.EmitEvent;
|
||||
event: eventWithTime;
|
||||
};
|
||||
180
packages/web-extension/src/utils/channel.ts
Normal file
180
packages/web-extension/src/utils/channel.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import mitt from 'mitt';
|
||||
import Browser, { Runtime } from 'webextension-polyfill';
|
||||
|
||||
export type Message = EventType | ServiceType;
|
||||
export type EventType = {
|
||||
type: 'event';
|
||||
event: string;
|
||||
detail: unknown;
|
||||
};
|
||||
export type ServiceType = {
|
||||
type: 'service';
|
||||
service: string;
|
||||
params: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Channel for inter-context communication.
|
||||
*
|
||||
* A chrome extension typically contains 4 types of context: background, popup, options and content scripts.
|
||||
* Communication between these contexts relies on
|
||||
* chrome.runtime.sendMessage and chrome.tabs.sendMessage.
|
||||
*
|
||||
* This Class provides two communication model:
|
||||
* * request/response
|
||||
* * event trigger/listen
|
||||
* based on chrome.runtime.sendMessage and chrome.tabs.sendMessage.
|
||||
*/
|
||||
class Channel {
|
||||
private services = new Map<
|
||||
string,
|
||||
(params: unknown, sender: Runtime.MessageSender) => Promise<unknown>
|
||||
>();
|
||||
private emitter = mitt();
|
||||
constructor() {
|
||||
/**
|
||||
* Register massage listener.
|
||||
*/
|
||||
Browser.runtime.onMessage.addListener(
|
||||
((message: string, sender: Runtime.MessageSender) => {
|
||||
const parsed = JSON.parse(message) as Message | null | undefined;
|
||||
if (!parsed || !parsed.type) {
|
||||
console.error(`Bad message: ${message}`);
|
||||
return;
|
||||
}
|
||||
switch (parsed.type) {
|
||||
case 'event':
|
||||
this.emitter.emit(parsed.event, { detail: parsed.detail, sender });
|
||||
break;
|
||||
case 'service': {
|
||||
const server = this.services.get(parsed.service);
|
||||
if (!server) break;
|
||||
return server(parsed.params, sender);
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
`Unknown message type: ${(parsed as { type: string }).type}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}).bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a service.
|
||||
*
|
||||
* @param serviceName - the name of the service, acts like a URL
|
||||
* @param serveFunction - a function to provide the service when a consumer request this service.
|
||||
* @returns a function to remove the service
|
||||
*/
|
||||
public provide(
|
||||
serviceName: string,
|
||||
serveFunction: (
|
||||
params: unknown,
|
||||
sender: Runtime.MessageSender,
|
||||
) => Promise<unknown>,
|
||||
): () => void {
|
||||
this.services.set(serviceName, serveFunction);
|
||||
return () => {
|
||||
this.services.delete(serviceName);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and get a response.
|
||||
*
|
||||
* @param service - service name to request
|
||||
* @param params - request parameters
|
||||
* @returns service data
|
||||
*/
|
||||
public request(
|
||||
serviceName: string,
|
||||
params: Record<string, unknown> | unknown,
|
||||
) {
|
||||
const message = JSON.stringify({
|
||||
type: 'service',
|
||||
service: serviceName,
|
||||
params,
|
||||
});
|
||||
return Browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the specified tab and get a response.
|
||||
*
|
||||
* @param tabId - tab id
|
||||
* @param service - service name to request
|
||||
* @param params - request parameters
|
||||
* @returns service data
|
||||
*/
|
||||
public requestToTab(
|
||||
tabId: number,
|
||||
serviceName: string,
|
||||
params: Record<string, unknown> | unknown,
|
||||
) {
|
||||
if (!Browser.tabs || !Browser.tabs.sendMessage)
|
||||
return Promise.reject('Can not send message to tabs in current context!');
|
||||
const message = JSON.stringify({
|
||||
type: 'service',
|
||||
service: serviceName,
|
||||
params,
|
||||
});
|
||||
return Browser.tabs.sendMessage(tabId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event handler.
|
||||
*
|
||||
* @param eventName - event name
|
||||
* @param handler - event handler, accepts two arguments:
|
||||
* detail: event detail
|
||||
* source: source of the event, chrome.runtime.MessageSender object
|
||||
* @returns a function to remove the handler
|
||||
*/
|
||||
public on(event: string, handler: (detail: unknown) => unknown) {
|
||||
return this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
*
|
||||
* @param event - event name
|
||||
* @param detail - event detail
|
||||
*/
|
||||
public emit(event: string, detail: unknown) {
|
||||
const message = JSON.stringify({ type: 'event', event, detail });
|
||||
void Browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to specified tabs.
|
||||
*
|
||||
* @param tabIds - tab ids
|
||||
* @param event - event name
|
||||
* @param detail - event detail
|
||||
*/
|
||||
public emitToTabs(tabIds: number | number[], event: string, detail: unknown) {
|
||||
if (!Browser.tabs || !Browser.tabs.sendMessage)
|
||||
return Promise.reject('Can not send message to tabs in current context!');
|
||||
|
||||
// If tabIds is a number, wrap it up with an array.
|
||||
if (typeof tabIds === 'number') {
|
||||
tabIds = [tabIds];
|
||||
}
|
||||
|
||||
const message = JSON.stringify({ type: 'event', event, detail });
|
||||
tabIds.forEach((tabId) => void Browser.tabs.sendMessage(tabId, message));
|
||||
}
|
||||
|
||||
public async getCurrentTabId() {
|
||||
const tabs = await Browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
return tabs[0].id || -1;
|
||||
}
|
||||
}
|
||||
|
||||
export default Channel;
|
||||
44
packages/web-extension/src/utils/index.ts
Normal file
44
packages/web-extension/src/utils/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export function isFirefox(): boolean {
|
||||
return window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
}
|
||||
|
||||
export function isInCrossOriginIFrame(): boolean {
|
||||
if (window.parent !== window) {
|
||||
try {
|
||||
void window.parent.location.origin;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
if (ms <= 0) {
|
||||
return '00:00';
|
||||
}
|
||||
const hour = Math.floor(ms / HOUR);
|
||||
ms = ms % HOUR;
|
||||
const minute = Math.floor(ms / MINUTE);
|
||||
ms = ms % MINUTE;
|
||||
const second = Math.floor(ms / SECOND);
|
||||
if (hour) {
|
||||
return `${padZero(hour)}:${padZero(minute)}:${padZero(second)}`;
|
||||
}
|
||||
return `${padZero(minute)}:${padZero(second)}`;
|
||||
}
|
||||
|
||||
function padZero(num: number, len = 2): string {
|
||||
let str = String(num);
|
||||
const threshold = Math.pow(10, len - 1);
|
||||
if (num < threshold) {
|
||||
while (String(threshold).length > str.length) {
|
||||
str = `0${num}`;
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
90
packages/web-extension/src/utils/recording.ts
Normal file
90
packages/web-extension/src/utils/recording.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
|
||||
import {
|
||||
LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
RecordStartedMessage,
|
||||
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;
|
||||
}
|
||||
90
packages/web-extension/src/utils/storage.ts
Normal file
90
packages/web-extension/src/utils/storage.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { openDB } from 'idb';
|
||||
import { eventWithTime } from '@rrweb/types';
|
||||
import { Session } from '~/types';
|
||||
|
||||
/**
|
||||
* Storage related functions with indexedDB.
|
||||
*/
|
||||
|
||||
const EventStoreName = 'events';
|
||||
type EventData = {
|
||||
id: string;
|
||||
events: eventWithTime[];
|
||||
};
|
||||
|
||||
export async function getEventStore() {
|
||||
return openDB<EventData>(EventStoreName, 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore(EventStoreName, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEvents(id: string) {
|
||||
const db = await getEventStore();
|
||||
const data = (await db.get(EventStoreName, id)) as EventData;
|
||||
return data.events;
|
||||
}
|
||||
|
||||
const SessionStoreName = 'sessions';
|
||||
export async function getSessionStore() {
|
||||
return openDB<Session>(SessionStoreName, 1, {
|
||||
upgrade(db) {
|
||||
// Create a store of objects
|
||||
db.createObjectStore(SessionStoreName, {
|
||||
// The 'id' property of the object will be the key.
|
||||
keyPath: 'id',
|
||||
// If it isn't explicitly set, create a value by auto incrementing.
|
||||
autoIncrement: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSession(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 getSession(id: string) {
|
||||
const store = await getSessionStore();
|
||||
return store.get(SessionStoreName, id) as Promise<Session>;
|
||||
}
|
||||
|
||||
export async function getAllSessions() {
|
||||
const store = await getSessionStore();
|
||||
const sessions = (await store.getAll(SessionStoreName)) as Session[];
|
||||
return sessions.sort((a, b) => b.createTimestamp - a.createTimestamp);
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string) {
|
||||
const eventStore = await getEventStore();
|
||||
const sessionStore = await getSessionStore();
|
||||
await Promise.all([
|
||||
eventStore.delete(EventStoreName, id),
|
||||
sessionStore.delete(SessionStoreName, id),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function deleteSessions(ids: string[]) {
|
||||
const eventStore = await getEventStore();
|
||||
const sessionStore = await getSessionStore();
|
||||
const eventTransition = eventStore.transaction(EventStoreName, 'readwrite');
|
||||
const sessionTransition = sessionStore.transaction(
|
||||
SessionStoreName,
|
||||
'readwrite',
|
||||
);
|
||||
const promises = [];
|
||||
for (const id of ids) {
|
||||
promises.push(eventTransition.store.delete(id));
|
||||
promises.push(sessionTransition.store.delete(id));
|
||||
}
|
||||
await Promise.all(promises).then(() => {
|
||||
return Promise.all([eventTransition.done, sessionTransition.done]);
|
||||
});
|
||||
}
|
||||
41
packages/web-extension/tsconfig.json
Normal file
41
packages/web-extension/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "es2016",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ESNext"
|
||||
],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../rrweb"
|
||||
},
|
||||
{
|
||||
"path": "../rrweb-player"
|
||||
},
|
||||
{
|
||||
"path": "../types"
|
||||
}
|
||||
]
|
||||
}
|
||||
106
packages/web-extension/vite.config.ts
Normal file
106
packages/web-extension/vite.config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
defineConfig,
|
||||
LibraryFormats,
|
||||
LibraryOptions,
|
||||
PluginOption,
|
||||
} from 'vite';
|
||||
import webExtension, { readJsonFile } from 'vite-plugin-web-extension';
|
||||
import zip from 'vite-plugin-zip';
|
||||
import * as path from 'path';
|
||||
import type { PackageJson } from 'type-fest';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
function useSpecialFormat(
|
||||
entriesToUse: string[],
|
||||
format: LibraryFormats,
|
||||
): PluginOption {
|
||||
return {
|
||||
name: 'use-special-format',
|
||||
config(config) {
|
||||
const shouldUse = entriesToUse.includes(
|
||||
(config.build?.lib as LibraryOptions)?.entry,
|
||||
);
|
||||
if (shouldUse) {
|
||||
config.build ??= {};
|
||||
// @ts-expect-error: lib needs to be an object, forcing it.
|
||||
config.build.lib ||= {};
|
||||
// @ts-expect-error: lib is an object
|
||||
config.build.lib.formats = [format];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: 'src',
|
||||
// Configure our outputs - nothing special, this is normal vite config
|
||||
build: {
|
||||
outDir: path.resolve(
|
||||
__dirname,
|
||||
'dist',
|
||||
process.env.TARGET_BROWSER as string,
|
||||
),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
// Add the webExtension plugin
|
||||
plugins: [
|
||||
react(),
|
||||
webExtension({
|
||||
// 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>;
|
||||
firefox: Record<string, unknown>;
|
||||
};
|
||||
const originalManifest = readJsonFile('./src/manifest.json') as {
|
||||
common: Record<string, unknown>;
|
||||
v2: ManifestBase;
|
||||
v3: ManifestBase;
|
||||
};
|
||||
const ManifestVersion =
|
||||
process.env.TARGET_BROWSER === 'chrome' && isProduction ? 'v3' : 'v2';
|
||||
const BrowserName =
|
||||
process.env.TARGET_BROWSER === 'chrome' ? 'chrome' : 'firefox';
|
||||
const commonManifest = originalManifest.common;
|
||||
const manifest = {
|
||||
version: packageJson.version,
|
||||
author: packageJson.author,
|
||||
version_name: packageJson.dependencies?.rrweb?.replace('^', ''),
|
||||
...commonManifest,
|
||||
};
|
||||
Object.assign(
|
||||
manifest,
|
||||
originalManifest[ManifestVersion].common,
|
||||
originalManifest[ManifestVersion][BrowserName],
|
||||
);
|
||||
return manifest;
|
||||
},
|
||||
assets: 'assets',
|
||||
browser: process.env.TARGET_BROWSER,
|
||||
webExtConfig: {
|
||||
startUrl: ['github.com/rrweb-io/rrweb'],
|
||||
watchIgnored: ['*.md', '*.log'],
|
||||
},
|
||||
additionalInputs: ['pages/index.html', 'content/inject.ts'],
|
||||
}),
|
||||
// https://github.com/aklinker1/vite-plugin-web-extension/issues/50#issuecomment-1317922947
|
||||
// transfer inject.ts to iife format to avoid error
|
||||
useSpecialFormat(
|
||||
[path.resolve(__dirname, 'src/content/inject.ts')],
|
||||
'iife',
|
||||
),
|
||||
process.env.ZIP === 'true' &&
|
||||
zip({
|
||||
dir: 'dist',
|
||||
outputName: process.env.TARGET_BROWSER,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user