Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ahmed/custom web extension #1589

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dev-web-extension:
@yarn install
@yarn dev:web-extension

build-web-extension:
@yarn workspace @rrweb/web-extension build
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"format": "yarn prettier --write '**/*.{ts,md}'",
"format:head": "git diff --name-only HEAD^ |grep '\\.ts$\\|\\.md$' |xargs yarn prettier --write",
"dev": "yarn turbo run dev --concurrency=18",
"dev:web-extension": "yarn workspace @rrweb/web-extension dev:chrome",
"repl": "cd packages/rrweb && npm run repl",
"live-stream": "cd packages/rrweb && yarn live-stream",
"lint": "yarn run concurrently --success=all -r -m=1 'yarn run markdownlint docs' 'yarn eslint packages/*/src --ext .ts,.tsx,.js,.jsx,.svelte'",
Expand All @@ -66,4 +67,4 @@
"not op_mini all"
],
"packageManager": "[email protected]"
}
}
145 changes: 108 additions & 37 deletions packages/web-extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
const channel = new Channel();

void (async () => {
// assign default value to settings of this extension
// Assign default value to settings of this extension
const result =
((await Browser.storage.sync.get(SyncDataKey.settings)) as SyncData) ||
undefined;
Expand All @@ -28,13 +28,17 @@
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.
// 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) => {
void (async () => {
try {
const data = await Browser.storage.local.get(
LocalDataKey.recorderStatus,
);
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;

let statusData = localData[LocalDataKey.recorderStatus];
let { status } = statusData;
let bufferedEvents: eventWithTime[] | undefined;
Expand All @@ -46,7 +50,7 @@
statusData,
).catch(async () => {
/**
* This error happen when the old tab is closed.
* This error happens 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.
*/
Expand All @@ -63,49 +67,74 @@
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
});

// Update the recorderStatus with new activeTabId
statusData.activeTabId = activeInfo.tabId;
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: statusData,
});

// Check if the new tab is fully loaded
const tab = await Browser.tabs.get(activeInfo.tabId);
if (tab.status === 'complete') {
// Tab is fully loaded, inject content script and resume recording
await injectContentScript(activeInfo.tabId);
if (statusData.status === RecorderStatus.PausedSwitch) {
await resumeRecording(
channel,
activeInfo.tabId,
statusData,
bufferedEvents,
);
}
} else {
// Tab is not fully loaded, wait for onUpdated event
// The onUpdated listener will handle resuming recording when the tab completes loading
}
} catch (error) {
console.error('Error in onActivated listener:', error);
}
})();
});

// 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) => {
// If the recording can't start on an invalid tab,
// resume it when the tab content is updated.
Browser.tabs.onUpdated.addListener((tabId, info) => {
void (async () => {
if (info.status !== 'complete') return;
try {
const data = await Browser.storage.local.get(
LocalDataKey.recorderStatus,
);
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
const { status, activeTabId } = localData[LocalDataKey.recorderStatus];
if (status !== RecorderStatus.PausedSwitch || activeTabId === tabId)
if (status !== RecorderStatus.PausedSwitch || activeTabId !== tabId)
return;

await injectContentScript(tabId);
await resumeRecording(
channel,
tabId,
localData[LocalDataKey.recorderStatus],
);
})
.catch(() => {
// the extension can't access to the tab
});
} catch (error) {
console.error('Error in onUpdated listener:', error);
}
})();
});

/**
* 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, 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) => {
void (async () => {
try {
const data = await Browser.storage.local.get(
LocalDataKey.recorderStatus,
);
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
const { status, activeTabId, startTimestamp } =
Expand All @@ -123,15 +152,15 @@
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: statusData,
});
})
.catch((err) => {
console.error(err);
});
} catch (err) {
console.error('Error in onRemoved listener:', err);
}
})();
});
})();

/**
* Update existed settings with new settings.
* Update existing settings with new settings.
* Set new setting values if these properties don't exist in older versions.
*/
function setDefaultSettings(
Expand Down Expand Up @@ -160,3 +189,45 @@
}
}
}

/**
* Checks if a URL is valid for content script injection.
* @param url - The URL to check.
* @returns True if the URL is injectable.
*/
function isInjectableUrl(url: string): boolean {
// We cannot inject into chrome://, chrome-extension://, or other browser-specific pages
const prohibitedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'moz-extension:',
'edge:',
'opera:',
'vivaldi:',
];
const urlObj = new URL(url);
return !prohibitedProtocols.includes(urlObj.protocol);
}

/**
* Injects the content script into the specified tab.
* @param tabId - The ID of the tab to inject the content script into.
*/
async function injectContentScript(tabId: number) {
try {
const tab = await Browser.tabs.get(tabId);
if (tab.url && isInjectableUrl(tab.url)) {
await Browser.scripting.executeScript({
target: { tabId },
files: ['content/index.js'], // Replace with your actual content script file(s)
});
} else {
console.warn(
`Cannot inject content script into tab ${tabId} with URL ${tab.url}`,

Check failure on line 227 in packages/web-extension/src/background/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/web-extension/src/background/index.ts#L227

[@typescript-eslint/restrict-template-expressions] Invalid type "string | undefined" of template literal expression.
);
}
} catch (err) {
console.error(`Failed to inject content script into tab ${tabId}:`, err);
}
}
27 changes: 10 additions & 17 deletions packages/web-extension/src/content/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Browser, { type Storage } from 'webextension-polyfill';

Check warning on line 1 in packages/web-extension/src/content/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/web-extension/src/content/index.ts#L1

[@typescript-eslint/no-unused-vars] 'Storage' is defined but never used.
import { nanoid } from 'nanoid';
import type { eventWithTime } from '@rrweb/types';
import {
Expand Down Expand Up @@ -34,7 +34,7 @@
recordCrossOriginIframes: true,
},
},
location.origin,
'*',
);
},
);
Expand Down Expand Up @@ -68,7 +68,7 @@
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.
// Adjust event timestamps to account for paused time
bufferedEvents.forEach((event) => {
event.timestamp += pausedTime;
});
Expand All @@ -79,7 +79,7 @@
let stopResponseCb: ((response: RecordStoppedMessage) => void) | undefined =
undefined;
channel.provide(ServiceName.StopRecord, () => {
window.postMessage({ message: MessageName.StopRecord });
window.postMessage({ message: MessageName.StopRecord }, '*');
return new Promise((resolve) => {
stopResponseCb = (response: RecordStoppedMessage) => {
stopResponseCb = undefined;
Expand All @@ -88,15 +88,15 @@
bufferedEvents = [];
newEvents = [];
resolve(response);
// clear cache
// Clear cache
void Browser.storage.local.set({
[LocalDataKey.bufferedEvents]: [],
});
};
});
});
channel.provide(ServiceName.PauseRecord, () => {
window.postMessage({ message: MessageName.StopRecord });
window.postMessage({ message: MessageName.StopRecord }, '*');
return new Promise((resolve) => {
stopResponseCb = (response: RecordStoppedMessage) => {
stopResponseCb = undefined;
Expand Down Expand Up @@ -133,7 +133,7 @@
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 = {
...data,
};
Expand All @@ -154,9 +154,8 @@
}

// Before unload pages, cache the new events in the local storage.
window.addEventListener('beforeunload', (event) => {
window.addEventListener('beforeunload', () => {
if (!newEvents.length) return;
event.preventDefault();
void Browser.storage.local.set({
[LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents),
});
Expand All @@ -166,17 +165,11 @@
async function initCrossOriginIframe() {
Browser.storage.local.onChanged.addListener((change) => {
if (change[LocalDataKey.recorderStatus]) {
const statusChange = change[
LocalDataKey.recorderStatus
] as Storage.StorageChange;
const statusChange = change[LocalDataKey.recorderStatus];
const newStatus =
statusChange.newValue as LocalData[LocalDataKey.recorderStatus];
if (newStatus.status === RecorderStatus.RECORDING) startRecord();
else
window.postMessage(
{ message: MessageName.StopRecord },
location.origin,
);
else window.postMessage({ message: MessageName.StopRecord }, '*');
}
});
const localData = (await Browser.storage.local.get()) as LocalData;
Expand All @@ -196,7 +189,7 @@
};
}

function generateSession() {
function generateSession(): Session {
const newSession: Session = {
id: nanoid(),
name: document.title,
Expand Down
4 changes: 2 additions & 2 deletions packages/web-extension/src/content/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const messageHandler = (
* Only post message in the main page.
*/
function postMessage(message: unknown) {
if (!isInCrossOriginIFrame()) window.postMessage(message, location.origin);
if (!isInCrossOriginIFrame()) window.postMessage(message, '*');
}

window.addEventListener('message', messageHandler);
Expand All @@ -74,5 +74,5 @@ window.postMessage(
{
message: MessageName.RecordScriptReady,
},
location.origin,
'*',
);
Loading
Loading