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