From 24f5fd99ba9490735420ce0275ff3874c9f2d120 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Mon, 20 Jan 2025 13:07:04 -0800 Subject: [PATCH] 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 --- .changeset/four-panthers-fly.md | 5 + .../web-extension/src/background/index.ts | 314 +++++++++++++----- packages/web-extension/src/content/index.ts | 81 +---- packages/web-extension/src/content/inject.ts | 4 - packages/web-extension/src/pages/Player.tsx | 3 + .../web-extension/src/pages/SessionList.tsx | 161 ++++++++- packages/web-extension/src/popup/App.tsx | 122 ++----- packages/web-extension/src/types.ts | 12 +- packages/web-extension/src/utils/index.ts | 6 +- packages/web-extension/src/utils/recording.ts | 90 ----- packages/web-extension/src/utils/storage.ts | 14 +- packages/web-extension/vite.config.ts | 3 +- 12 files changed, 433 insertions(+), 382 deletions(-) create mode 100644 .changeset/four-panthers-fly.md delete mode 100644 packages/web-extension/src/utils/recording.ts diff --git a/.changeset/four-panthers-fly.md b/.changeset/four-panthers-fly.md new file mode 100644 index 0000000000..6bb92a03f8 --- /dev/null +++ b/.changeset/four-panthers-fly.md @@ -0,0 +1,5 @@ +--- +"@rrweb/web-extension": patch +--- + +web-extension: improve recording stability across tabs and enable session import diff --git a/packages/web-extension/src/background/index.ts b/packages/web-extension/src/background/index.ts index f553a3c742..ed299a2007 100644 --- a/packages/web-extension/src/background/index.ts +++ b/packages/web-extension/src/background/index.ts @@ -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); - // 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, - ); - }) + 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(() => { - // the extension can't access to the tab + // 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) => { + 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; +} diff --git a/packages/web-extension/src/content/index.ts b/packages/web-extension/src/content/index.ts index 0fb866c1e5..baf1c8de8a 100644 --- a/packages/web-extension/src/content/index.ts +++ b/packages/web-extension/src/content/index.ts @@ -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; -} diff --git a/packages/web-extension/src/content/inject.ts b/packages/web-extension/src/content/inject.ts index b83a343983..82c59d4602 100644 --- a/packages/web-extension/src/content/inject.ts +++ b/packages/web-extension/src/content/inject.ts @@ -8,15 +8,12 @@ import { isInCrossOriginIFrame } from '~/utils'; * This script is injected into both main page and cross-origin IFrames through