1397 lines
40 KiB
TypeScript
1397 lines
40 KiB
TypeScript
import { WebRequest } from "webextension-polyfill";
|
|
|
|
export default defineBackground(() => {
|
|
const CHECK_INT = 60 * 1000;
|
|
const PING_INT = 30 * 1000;
|
|
const messages = {
|
|
popup: {
|
|
from: {
|
|
updateSettings: "ort:settings",
|
|
start: "popup:start",
|
|
},
|
|
to: {
|
|
micStatus: "popup:mic-status",
|
|
stopped: "popup:stopped",
|
|
started: "popup:started",
|
|
noLogin: "popup:no-login",
|
|
},
|
|
stop: "popup:stop",
|
|
checkStatus: "popup:check-status",
|
|
loginExist: "popup:login",
|
|
getAudioPerms: "popup:get-audio-perm",
|
|
},
|
|
content: {
|
|
from: {
|
|
bumpVitals: "ort:bump-vitals",
|
|
bumpClicks: "ort:bump-clicks",
|
|
bumpLocation: "ort:bump-location",
|
|
discard: "ort:discard",
|
|
checkLogin: "ort:get-login",
|
|
checkRecStatus: "ort:check-status",
|
|
checkMicStatus: "ort:getMicStatus",
|
|
setLoginToken: "ort:login-token",
|
|
invalidateToken: "ort:invalidate-token",
|
|
saveSpotData: "ort:save-spot",
|
|
saveSpotVidChunk: "ort:save-spot-part",
|
|
countEnd: "ort:countend",
|
|
contentReady: "ort:content-ready",
|
|
checkNewTab: "ort:check-new-tab",
|
|
started: "ort:started",
|
|
stopped: "ort:stopped",
|
|
restart: "ort:restart",
|
|
getErrorEvents: "ort:get-error-events",
|
|
},
|
|
to: {
|
|
setJWT: "content:set-jwt",
|
|
micStatus: "content:mic-status",
|
|
unmount: "content:unmount",
|
|
notification: "notif:display",
|
|
updateErrorEvents: "content:error-events",
|
|
},
|
|
},
|
|
injected: {
|
|
from: {
|
|
bumpLogs: "ort:bump-logs",
|
|
},
|
|
},
|
|
offscreen: {
|
|
to: {
|
|
checkRecStatus: "offscr:check-status",
|
|
startRecording: "offscr:start-recording",
|
|
},
|
|
},
|
|
};
|
|
|
|
interface SpotObj {
|
|
name: string;
|
|
comment: string;
|
|
useHook: string;
|
|
preview: string;
|
|
base64data: string;
|
|
duration: number;
|
|
network: SpotNetworkRequest[];
|
|
logs: { level: string; msg: string; time: number }[];
|
|
clicks: { time: number; label: string }[];
|
|
crop: [number, number] | null;
|
|
locations: {
|
|
time: number;
|
|
location: string;
|
|
navTiming: {
|
|
fcpTime: number;
|
|
visuallyComplete: number;
|
|
timeToInteractive: number;
|
|
};
|
|
}[];
|
|
vitals: {
|
|
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
|
|
value: number;
|
|
}[];
|
|
startTs: number;
|
|
browserName: string;
|
|
browserVersion: string;
|
|
platform: string;
|
|
resolution: string;
|
|
}
|
|
|
|
const REC_STATE = {
|
|
recording: "recording",
|
|
paused: "paused",
|
|
stopped: "stopped",
|
|
};
|
|
|
|
const defaultSpotObj = {
|
|
name: "",
|
|
comment: "",
|
|
useHook: "",
|
|
preview: "",
|
|
base64data: "",
|
|
duration: 100,
|
|
network: [],
|
|
logs: [],
|
|
clicks: [],
|
|
locations: [],
|
|
vitals: [],
|
|
startTs: 0,
|
|
browserName: "chrome",
|
|
browserVersion: "",
|
|
platform: "",
|
|
resolution: "",
|
|
crop: null,
|
|
};
|
|
let contentArmy: Record<any, boolean> = {};
|
|
let micStatus = "off";
|
|
let finalVideoBase64 = "";
|
|
let finalReady = false;
|
|
let finalSpotObj: SpotObj = defaultSpotObj;
|
|
let onStop: (() => void) | null = null;
|
|
let settings = {
|
|
openInNewTab: true,
|
|
consoleLogs: true,
|
|
networkLogs: true,
|
|
ingestPoint: "https://foss.openreplay.com",
|
|
};
|
|
let recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
audioPerm: 0,
|
|
} as Record<string, any>;
|
|
let jwtToken = "";
|
|
|
|
function setJWTToken(token: string) {
|
|
jwtToken = token;
|
|
if (token && token.length) {
|
|
void browser.storage.local.set({ jwtToken: token });
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.loginExist,
|
|
});
|
|
} else {
|
|
void browser.storage.local.remove("jwtToken");
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.noLogin,
|
|
});
|
|
}
|
|
}
|
|
|
|
function safeApiUrl(url: string) {
|
|
let str = url;
|
|
if (str.endsWith("/")) {
|
|
str = str.slice(0, -1);
|
|
}
|
|
if (str.includes("app.openreplay.com")) {
|
|
str = "https://api.openreplay.com";
|
|
}
|
|
return str;
|
|
}
|
|
|
|
let slackChannels: { name: string; webhookId: number }[] = [];
|
|
const refreshToken = async () => {
|
|
const data = await browser.storage.local.get(["jwtToken", "settings"]);
|
|
if (!data.settings) {
|
|
return;
|
|
}
|
|
const { jwtToken, settings } = data;
|
|
const ingest = safeApiUrl(settings.ingestPoint);
|
|
const refreshUrl = safeApiUrl(`${ingest}/api`);
|
|
if (!isTokenExpired(jwtToken) || !jwtToken) {
|
|
if (refreshInt) {
|
|
clearInterval(refreshInt);
|
|
}
|
|
return true;
|
|
}
|
|
const resp = await fetch(`${refreshUrl}/spot/refresh`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${jwtToken}`,
|
|
},
|
|
});
|
|
if (!resp.ok) {
|
|
void browser.storage.local.remove("jwtToken");
|
|
setJWTToken("");
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.noLogin,
|
|
});
|
|
return false;
|
|
}
|
|
const dataObj = await resp.json();
|
|
const refreshedJwt = dataObj.jwt;
|
|
setJWTToken(refreshedJwt);
|
|
return true;
|
|
};
|
|
|
|
const fetchSlackChannels = async (token: string, ingest: string) => {
|
|
await refreshToken();
|
|
const resp = await fetch(`${ingest}/spot/integrations/slack/channels`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
if (resp.ok) {
|
|
const { data } = await resp.json();
|
|
slackChannels = data.map((channel: Record<string, any>) => ({
|
|
name: channel.name,
|
|
webhookId: channel.webhookId,
|
|
}));
|
|
}
|
|
};
|
|
let refreshInt: any;
|
|
let pingInt: any;
|
|
browser.storage.local
|
|
.get(["jwtToken", "settings"])
|
|
.then(async (data: any) => {
|
|
if (!data.settings) {
|
|
void browser.storage.local.set({ settings });
|
|
return;
|
|
}
|
|
settings = Object.assign(settings, data.settings);
|
|
|
|
if (!data.jwtToken) {
|
|
console.error("No JWT token found in storage");
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.noLogin,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const url = safeApiUrl(`${data.settings.ingestPoint}/api`);
|
|
const ok = await refreshToken();
|
|
if (ok) {
|
|
fetchSlackChannels(data.jwtToken, url).catch((e) => {
|
|
console.error(e);
|
|
void refreshToken();
|
|
});
|
|
|
|
if (!refreshInt) {
|
|
refreshInt = setInterval(() => {
|
|
void refreshToken();
|
|
}, CHECK_INT);
|
|
}
|
|
|
|
if (!pingInt) {
|
|
pingInt = setInterval(() => {
|
|
void pingJWT();
|
|
}, PING_INT);
|
|
}
|
|
}
|
|
});
|
|
|
|
async function pingJWT(): Promise<void> {
|
|
const data = await browser.storage.local.get(["jwtToken", "settings"]);
|
|
if (!data.settings) {
|
|
return;
|
|
}
|
|
const { jwtToken, settings } = data;
|
|
const ingest = safeApiUrl(settings.ingestPoint);
|
|
if (!jwtToken) {
|
|
if (pingInt) {
|
|
clearInterval(pingInt);
|
|
}
|
|
return;
|
|
}
|
|
const url = safeApiUrl(`${ingest}/spot/v1/ping`);
|
|
try {
|
|
const r = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${jwtToken}`,
|
|
},
|
|
});
|
|
if (!r.ok) {
|
|
void refreshToken();
|
|
}
|
|
} catch (e) {
|
|
void refreshToken();
|
|
}
|
|
}
|
|
|
|
let lastReq: Record<string, any> | null = null;
|
|
// @ts-ignore
|
|
browser.runtime.onMessage.addListener((request, sender, respond) => {
|
|
if (request.type === messages.content.from.contentReady) {
|
|
if (sender?.tab?.id) {
|
|
contentArmy[sender.tab.id] = true;
|
|
}
|
|
}
|
|
if (request.type === messages.popup.from.start) {
|
|
lastReq = request;
|
|
finalSpotObj.logs = [];
|
|
finalSpotObj.clicks = [];
|
|
finalSpotObj.locations = [];
|
|
finalSpotObj.vitals = [];
|
|
finalSpotObj.network = [];
|
|
finalSpotObj = defaultSpotObj;
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
audioPerm: request.permissions ? (request.mic ? 2 : 1) : 0,
|
|
};
|
|
if (request.area === "tab") {
|
|
browser.tabs
|
|
.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
})
|
|
.then((tabs) => {
|
|
const active = tabs[0];
|
|
if (active) {
|
|
recordingState.activeTabId = active.id;
|
|
}
|
|
void sendToActiveTab({
|
|
type: "content:mount",
|
|
area: request.area,
|
|
mic: request.mic,
|
|
audioId: request.selectedAudioDevice,
|
|
audioPerm: request.permissions ? (request.mic ? 2 : 1) : 0,
|
|
});
|
|
});
|
|
} else {
|
|
void sendToActiveTab({
|
|
type: "content:mount",
|
|
area: request.area,
|
|
mic: request.mic,
|
|
audioId: request.selectedAudioDevice,
|
|
audioPerm: request.permissions ? (request.mic ? 2 : 1) : 0,
|
|
});
|
|
}
|
|
}
|
|
if (request.type === messages.content.from.countEnd) {
|
|
if (!jwtToken) {
|
|
browser.storage.local.get("jwtToken").then((data: any) => {
|
|
if (!data.jwtToken) {
|
|
console.error("No JWT token found");
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.noLogin,
|
|
});
|
|
return;
|
|
}
|
|
setJWTToken(data.jwtToken);
|
|
});
|
|
}
|
|
finalVideoBase64 = "";
|
|
const recArea = request.area;
|
|
finalSpotObj.startTs = Date.now();
|
|
if (recArea === "tab") {
|
|
function signalTabRecording() {
|
|
recordingState = {
|
|
activeTabId: recordingState.activeTabId,
|
|
area: "tab",
|
|
recording: REC_STATE.recording,
|
|
};
|
|
const microphone = request.mic;
|
|
micStatus = microphone ? "on" : "off";
|
|
void startRecording(
|
|
recArea,
|
|
microphone,
|
|
request.audioId,
|
|
slackChannels,
|
|
recordingState.activeTabId,
|
|
settings.networkLogs,
|
|
settings.consoleLogs,
|
|
() => recordingState.recording,
|
|
);
|
|
// @ts-ignore this is false positive
|
|
respond(true);
|
|
}
|
|
if (!recordingState.activeTabId) {
|
|
browser.tabs
|
|
.query({ active: true, currentWindow: true })
|
|
.then((t) => {
|
|
recordingState.activeTabId = t[0].id;
|
|
signalTabRecording();
|
|
});
|
|
}
|
|
if (recordingState.activeTabId) {
|
|
new Promise((r) => {
|
|
signalTabRecording();
|
|
r(true);
|
|
});
|
|
}
|
|
return true;
|
|
} else {
|
|
const microphone = request.mic;
|
|
micStatus = microphone ? "on" : "off";
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: "desktop",
|
|
recording: REC_STATE.recording,
|
|
};
|
|
startRecording(
|
|
recArea,
|
|
microphone,
|
|
request.audioId,
|
|
slackChannels,
|
|
undefined,
|
|
settings.networkLogs,
|
|
settings.consoleLogs,
|
|
() => recordingState.recording,
|
|
(hook) => {
|
|
onStop = hook;
|
|
},
|
|
).then(() => {
|
|
// @ts-ignore this is false positive
|
|
respond(true);
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
if (request.type === messages.popup.checkStatus) {
|
|
if (jwtToken) {
|
|
void browser.runtime.sendMessage({ type: messages.popup.loginExist });
|
|
if (recordingState.recording !== REC_STATE.stopped) {
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.started,
|
|
});
|
|
}
|
|
} else {
|
|
void browser.runtime.sendMessage({ type: messages.popup.to.noLogin });
|
|
}
|
|
}
|
|
if (request.type === messages.popup.stop) {
|
|
if (onStop) {
|
|
onStop();
|
|
}
|
|
void sendToActiveTab({
|
|
type: "content:stop",
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.checkNewTab) {
|
|
browser.storage.local.get("settings").then((data: any) => {
|
|
// @ts-ignore this is false positive
|
|
respond(Boolean(data.settings.openInNewTab));
|
|
});
|
|
|
|
return true;
|
|
}
|
|
if (request.type === messages.content.from.started) {
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.started,
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.stopped) {
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.stopped,
|
|
});
|
|
}
|
|
if (request.type === messages.popup.getAudioPerms) {
|
|
void browser.tabs.create({
|
|
url: browser.runtime.getURL("/audio.html"),
|
|
active: true,
|
|
});
|
|
}
|
|
if (request.type === "audio:audio-perm") {
|
|
void browser.runtime.sendMessage({
|
|
type: "popup:audio-perm",
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.setLoginToken) {
|
|
setJWTToken(request.token);
|
|
browser.storage.local.get("settings").then((data: any) => {
|
|
if (!data.settings) {
|
|
void browser.storage.local.set({ settings });
|
|
return;
|
|
}
|
|
if (!refreshInt) {
|
|
refreshInt = setInterval(() => {
|
|
void refreshToken();
|
|
}, CHECK_INT);
|
|
}
|
|
if (!pingInt) {
|
|
pingInt = setInterval(() => {
|
|
void pingJWT();
|
|
}, PING_INT);
|
|
}
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.invalidateToken) {
|
|
if (refreshInt) {
|
|
clearInterval(refreshInt);
|
|
}
|
|
if (pingInt) {
|
|
clearInterval(pingInt);
|
|
}
|
|
setJWTToken("");
|
|
}
|
|
if (request.type === messages.content.from.checkMicStatus) {
|
|
void sendToActiveTab({
|
|
type: messages.content.to.micStatus,
|
|
micStatus: micStatus === "on",
|
|
});
|
|
}
|
|
if (request.type === messages.popup.from.updateSettings) {
|
|
const updatedObject = Object.assign(settings, request.settings);
|
|
settings = updatedObject;
|
|
if ("ingestPoint" in request.settings) {
|
|
setJWTToken("");
|
|
}
|
|
void browser.storage.local.set({ settings: updatedObject });
|
|
}
|
|
if (request.type === messages.content.from.checkRecStatus) {
|
|
const id = request.tabId;
|
|
if (recordingState.area === "tab" && id !== recordingState.activeTabId) {
|
|
// @ts-ignore this is false positive
|
|
respond({ status: false });
|
|
} else {
|
|
browser.runtime
|
|
.sendMessage({
|
|
type: messages.offscreen.to.checkRecStatus,
|
|
target: "offscreen",
|
|
})
|
|
.then((r) => {
|
|
// @ts-ignore this is false positive
|
|
respond({ ...r, state: recordingState.recording });
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
if (request.type === messages.content.from.checkLogin) {
|
|
if (jwtToken && jwtToken.length) {
|
|
void sendToActiveTab({
|
|
type: messages.content.to.setJWT,
|
|
jwtToken,
|
|
});
|
|
} else {
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.noLogin,
|
|
});
|
|
}
|
|
}
|
|
if (request.type === messages.injected.from.bumpLogs) {
|
|
finalSpotObj.logs.push(...request.logs);
|
|
return "pong";
|
|
}
|
|
if (request.type === messages.content.from.bumpClicks) {
|
|
finalSpotObj.clicks.push(...request.clicks);
|
|
return "pong";
|
|
}
|
|
if (request.type === messages.content.from.bumpLocation) {
|
|
finalSpotObj.locations.push(request.location);
|
|
return "pong";
|
|
}
|
|
if (request.type === messages.content.from.bumpVitals) {
|
|
finalSpotObj.vitals.push(request.vitals);
|
|
return "pong";
|
|
}
|
|
if (request.type === messages.content.from.discard) {
|
|
finalSpotObj = {
|
|
name: "",
|
|
comment: "",
|
|
useHook: "",
|
|
preview: "",
|
|
base64data: "",
|
|
duration: 100,
|
|
network: [],
|
|
logs: [],
|
|
clicks: [],
|
|
locations: [],
|
|
vitals: [],
|
|
startTs: 0,
|
|
browserName: "chrome",
|
|
browserVersion: "",
|
|
platform: "",
|
|
resolution: "",
|
|
crop: null,
|
|
};
|
|
finalVideoBase64 = "";
|
|
finalReady = false;
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
};
|
|
}
|
|
if (request.type === messages.content.from.restart) {
|
|
void browser.runtime.sendMessage({
|
|
type: "offscr:stop-discard",
|
|
target: "offscreen",
|
|
});
|
|
finalVideoBase64 = "";
|
|
finalReady = false;
|
|
finalSpotObj = defaultSpotObj;
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
audioPerm: lastReq!.permissions,
|
|
};
|
|
void sendToActiveTab({
|
|
type: "content:mount",
|
|
area: lastReq!.area,
|
|
mic: lastReq!.mic,
|
|
audioId: lastReq!.selectedAudioDevice,
|
|
audioPerm: lastReq!.permissions,
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.getErrorEvents) {
|
|
const logs = finalSpotObj.logs
|
|
.filter((log) => log.level === "error")
|
|
.map((l) => ({
|
|
title: "JS Error",
|
|
time: (l.time - finalSpotObj.startTs) / 1000,
|
|
}));
|
|
const network = finalSpotObj.network
|
|
.filter((net) => net.statusCode >= 400 || net.error)
|
|
.map((n) => ({
|
|
title: "Network Error",
|
|
time: (n.time - finalSpotObj.startTs) / 1000,
|
|
}));
|
|
|
|
const errorData = [...logs, ...network].sort((a, b) => a.time - b.time);
|
|
|
|
void sendToActiveTab({
|
|
type: messages.content.to.updateErrorEvents,
|
|
errorData,
|
|
});
|
|
}
|
|
if (request.type === "ort:stop") {
|
|
browser.runtime
|
|
.sendMessage({
|
|
type: "offscr:stop-recording",
|
|
target: "offscreen",
|
|
})
|
|
.then((r) => {
|
|
if (r.status === "full") {
|
|
finalSpotObj.duration = r.duration;
|
|
// @ts-ignore this is false positive
|
|
respond(r);
|
|
} else {
|
|
// @ts-ignore this is false positive
|
|
respond({ status: r.status });
|
|
}
|
|
});
|
|
onStop?.();
|
|
recordingState.recording = REC_STATE.stopped;
|
|
|
|
return true;
|
|
}
|
|
if (request.type === "offscr:video-data-chunk") {
|
|
finalSpotObj.duration = request.duration;
|
|
void sendToActiveTab({
|
|
type: "content:video-chunk",
|
|
data: request.data,
|
|
index: request.index,
|
|
total: request.total,
|
|
});
|
|
}
|
|
if (request.type === "ort:pause") {
|
|
void sendToOffscreen({
|
|
type: "offscr:pause-recording",
|
|
target: "offscreen",
|
|
});
|
|
recordingState.recording = REC_STATE.paused;
|
|
return "pong";
|
|
}
|
|
if (request.type === "ort:resume") {
|
|
void sendToOffscreen({
|
|
type: "offscr:resume-recording",
|
|
target: "offscreen",
|
|
});
|
|
recordingState.recording = REC_STATE.recording;
|
|
return "pong";
|
|
}
|
|
if (request.type === "ort:mute-microphone") {
|
|
micStatus = "off";
|
|
void sendToOffscreen({
|
|
type: "offscr:mute-microphone",
|
|
target: "offscreen",
|
|
});
|
|
void sendToActiveTab({
|
|
type: "content:mic-status",
|
|
micStatus: micStatus === "on",
|
|
});
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.micStatus,
|
|
status: false,
|
|
});
|
|
}
|
|
if (request.type === "ort:unmute-microphone") {
|
|
micStatus = "on";
|
|
void sendToOffscreen({
|
|
type: "offscr:unmute-microphone",
|
|
target: "offscreen",
|
|
});
|
|
void sendToActiveTab({
|
|
type: "content:mic-status",
|
|
micStatus: micStatus === "on",
|
|
});
|
|
void browser.runtime.sendMessage({
|
|
type: messages.popup.to.micStatus,
|
|
status: true,
|
|
});
|
|
}
|
|
if (request.type === messages.content.from.saveSpotData) {
|
|
stopTrackingNetwork();
|
|
const finalNetwork: SpotNetworkRequest[] = [];
|
|
const tab =
|
|
recordingState.area === "tab" ? recordingState.activeTabId : undefined;
|
|
let lastIn = 0;
|
|
try {
|
|
rawRequests.forEach((r, i) => {
|
|
lastIn = i;
|
|
const spotNetworkRequest = createSpotNetworkRequest(r, tab);
|
|
if (spotNetworkRequest) {
|
|
finalNetwork.push(spotNetworkRequest);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error("cant parse network", e, rawRequests[lastIn]);
|
|
}
|
|
Object.assign(finalSpotObj, request.spot, {
|
|
network: finalNetwork,
|
|
});
|
|
return "pong";
|
|
}
|
|
if (request.type === messages.content.from.saveSpotVidChunk) {
|
|
finalVideoBase64 += request.part;
|
|
finalReady = request.index === request.total - 1;
|
|
const getPlatformData = async () => {
|
|
const vendor = await browser.runtime.getPlatformInfo();
|
|
const platform = `${vendor.os} ${vendor.arch}`;
|
|
return { platform };
|
|
};
|
|
if (finalReady) {
|
|
const duration = finalSpotObj.crop
|
|
? finalSpotObj.crop[1] - finalSpotObj.crop[0]
|
|
: finalSpotObj.duration;
|
|
const dataObj = {
|
|
name: finalSpotObj.name,
|
|
comment: finalSpotObj.comment,
|
|
preview: finalSpotObj.preview,
|
|
// duration: finalSpotObj.duration,
|
|
duration: duration,
|
|
crop: finalSpotObj.crop,
|
|
vitals: finalSpotObj.vitals,
|
|
};
|
|
const videoData = finalVideoBase64;
|
|
|
|
getPlatformData().then(({ platform }) => {
|
|
const cropped =
|
|
finalSpotObj.crop &&
|
|
finalSpotObj.crop[0] + finalSpotObj.crop[1] > 0;
|
|
const logs = [];
|
|
const network = [];
|
|
const locations = [];
|
|
if (!cropped) {
|
|
logs.push(...finalSpotObj.logs);
|
|
network.push(...finalSpotObj.network);
|
|
locations.push(...finalSpotObj.locations);
|
|
} else {
|
|
const start =
|
|
finalSpotObj.startTs +
|
|
(finalSpotObj.crop ? finalSpotObj.crop[0] : 0);
|
|
const end =
|
|
finalSpotObj.startTs +
|
|
(finalSpotObj.crop ? finalSpotObj.crop[1] : 0);
|
|
logs.push(
|
|
...finalSpotObj.logs.filter(
|
|
(log) => log.time >= start && log.time <= end,
|
|
),
|
|
);
|
|
network.push(
|
|
...finalSpotObj.network.filter(
|
|
(net) => net.time >= start && net.time <= end,
|
|
),
|
|
);
|
|
locations.push(
|
|
...finalSpotObj.locations.filter(
|
|
(loc) => loc.time >= start && loc.time <= end,
|
|
),
|
|
);
|
|
}
|
|
const mobData = Object.assign(
|
|
{},
|
|
{
|
|
clicks: finalSpotObj.clicks,
|
|
logs,
|
|
network,
|
|
locations,
|
|
startTs: finalSpotObj.startTs,
|
|
resolution: finalSpotObj.resolution,
|
|
browserVersion: finalSpotObj.browserVersion,
|
|
platform,
|
|
},
|
|
);
|
|
|
|
const ingestUrl = safeApiUrl(settings.ingestPoint);
|
|
|
|
const dataUrl = `${ingestUrl}/spot/v1/spots`;
|
|
refreshToken()
|
|
.then((r) => {
|
|
if (!r) {
|
|
void sendToActiveTab({
|
|
type: messages.content.to.notification,
|
|
message: `Error saving Spot: couldn't get active login`,
|
|
});
|
|
}
|
|
fetch(dataUrl, {
|
|
method: "POST",
|
|
body: JSON.stringify(dataObj),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${jwtToken}`,
|
|
},
|
|
})
|
|
.then((r) => {
|
|
if (r.ok) {
|
|
return r.json();
|
|
} else {
|
|
if (r.status === 401) {
|
|
throw new Error(
|
|
"Not authorized or no permissions to create Spot",
|
|
);
|
|
}
|
|
}
|
|
})
|
|
.then(async (resp) => {
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
};
|
|
// id of spot, mobURL - for events, videoURL - for video
|
|
if (!resp || !resp.id) {
|
|
return sendToActiveTab({
|
|
type: messages.content.to.notification,
|
|
message: "Couldn't save Spot",
|
|
});
|
|
}
|
|
const { id, mobURL, videoURL } = resp;
|
|
const link = settings.ingestPoint.includes(
|
|
"api.openreplay.com",
|
|
)
|
|
? "https://app.openreplay.com"
|
|
: settings.ingestPoint;
|
|
void browser.tabs.create({
|
|
url: `${link}/view-spot/${id}`,
|
|
active: settings.openInNewTab,
|
|
});
|
|
void sendToActiveTab({
|
|
type: "content:spot-saved",
|
|
url: `${link}/view-spot/${id}`,
|
|
});
|
|
const blob = base64ToBlob(videoData);
|
|
|
|
const mPromise = fetch(mobURL, {
|
|
method: "PUT",
|
|
body: JSON.stringify(mobData),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
const vPromise = fetch(videoURL, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "video/webm",
|
|
},
|
|
body: blob,
|
|
});
|
|
Promise.all([mPromise, vPromise])
|
|
.then(async () => {
|
|
const uploadedUrl = `${safeApiUrl(settings.ingestPoint)}/spot/v1/spots/${id}/uploaded`;
|
|
void fetch(uploadedUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwtToken}`,
|
|
},
|
|
});
|
|
})
|
|
.catch(console.error);
|
|
})
|
|
.catch((e) => {
|
|
console.error(e);
|
|
void sendToActiveTab({
|
|
type: messages.content.to.notification,
|
|
message: `Error saving Spot: ${e.message}`,
|
|
});
|
|
})
|
|
.finally(() => {
|
|
finalSpotObj = defaultSpotObj;
|
|
});
|
|
})
|
|
.catch((e) => {
|
|
void sendToActiveTab({
|
|
type: messages.content.to.notification,
|
|
message: `Error saving Spot: ${e.message}`,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
return "pong";
|
|
}
|
|
});
|
|
|
|
void browser.runtime.setUninstallURL("https://forms.gle/sMo8da2AvrPg5o7YA");
|
|
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
|
// Also fired on update and browser_update
|
|
if (reason !== "install") return;
|
|
|
|
await browser.tabs.create({
|
|
url: "https://www.openreplay.com/spot/welcome?ref=extension",
|
|
active: true,
|
|
});
|
|
});
|
|
void initializeOffscreenDocument();
|
|
|
|
type TrackedRequest = {
|
|
statusCode: number;
|
|
requestHeaders: Record<string, string>;
|
|
responseHeaders: Record<string, string>;
|
|
} & (
|
|
| WebRequest.OnBeforeRequestDetailsType
|
|
| WebRequest.OnBeforeSendHeadersDetailsType
|
|
| WebRequest.OnCompletedDetailsType
|
|
| WebRequest.OnErrorOccurredDetailsType
|
|
| WebRequest.OnResponseStartedDetailsType
|
|
);
|
|
|
|
interface SpotNetworkRequest {
|
|
encodedBodySize: number;
|
|
responseBodySize: number;
|
|
duration: number;
|
|
method: TrackedRequest["method"];
|
|
type: string;
|
|
time: TrackedRequest["timeStamp"];
|
|
statusCode: number;
|
|
error?: string;
|
|
url: TrackedRequest["url"];
|
|
fromCache: boolean;
|
|
body: string;
|
|
requestHeaders: Record<string, string>;
|
|
responseHeaders: Record<string, string>;
|
|
}
|
|
const rawRequests: (TrackedRequest & {
|
|
startTs: number;
|
|
duration: number;
|
|
})[] = [];
|
|
function filterHeaders(headers: Record<string, string>) {
|
|
const filteredHeaders: Record<string, string> = {};
|
|
const privateHs = [
|
|
"x-api-key",
|
|
"www-authenticate",
|
|
"x-csrf-token",
|
|
"x-requested-with",
|
|
"x-forwarded-for",
|
|
"x-real-ip",
|
|
"cookie",
|
|
"authorization",
|
|
"auth",
|
|
"proxy-authorization",
|
|
"set-cookie",
|
|
];
|
|
if (Array.isArray(headers)) {
|
|
headers.forEach(({ name, value }) => {
|
|
if (privateHs.includes(name.toLowerCase())) {
|
|
return;
|
|
} else {
|
|
filteredHeaders[name] = value;
|
|
}
|
|
});
|
|
} else {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (!privateHs.includes(key.toLowerCase())) {
|
|
filteredHeaders[key] = value;
|
|
}
|
|
}
|
|
}
|
|
return filteredHeaders;
|
|
}
|
|
function createSpotNetworkRequest(
|
|
trackedRequest: TrackedRequest,
|
|
trackedTab?: number,
|
|
) {
|
|
if (trackedRequest.tabId === -1) {
|
|
return;
|
|
}
|
|
if (trackedTab && trackedTab !== trackedRequest.tabId) {
|
|
return;
|
|
}
|
|
if (
|
|
["ping", "beacon", "image", "script", "font"].includes(
|
|
trackedRequest.type,
|
|
)
|
|
) {
|
|
if (!trackedRequest.statusCode || trackedRequest.statusCode < 400) {
|
|
return;
|
|
}
|
|
}
|
|
const type = ["stylesheet", "script", "image", "media", "font"].includes(
|
|
trackedRequest.type,
|
|
)
|
|
? "resource"
|
|
: trackedRequest.type;
|
|
|
|
const requestHeaders = trackedRequest.requestHeaders
|
|
? filterHeaders(trackedRequest.requestHeaders)
|
|
: {};
|
|
const responseHeaders = trackedRequest.responseHeaders
|
|
? filterHeaders(trackedRequest.responseHeaders)
|
|
: {};
|
|
|
|
const reqSize = trackedRequest.reqBody
|
|
? trackedRequest.requestSize || trackedRequest.reqBody.length
|
|
: 0;
|
|
|
|
const status = getRequestStatus(trackedRequest);
|
|
const request: SpotNetworkRequest = {
|
|
method: trackedRequest.method,
|
|
type,
|
|
body: trackedRequest.reqBody,
|
|
requestHeaders,
|
|
responseHeaders,
|
|
time: trackedRequest.timeStamp,
|
|
statusCode: status,
|
|
error: trackedRequest.error,
|
|
url: trackedRequest.url,
|
|
fromCache: trackedRequest.fromCache || false,
|
|
encodedBodySize: reqSize,
|
|
responseBodySize: trackedRequest.responseSize,
|
|
duration: trackedRequest.duration,
|
|
};
|
|
|
|
return request;
|
|
}
|
|
|
|
function modifyOnSpot(request: TrackedRequest) {
|
|
const id = request.requestId;
|
|
const index = rawRequests.findIndex((r) => r.requestId === id);
|
|
const ts = Date.now();
|
|
const start = rawRequests[index]?.startTs ?? ts;
|
|
rawRequests[index] = {
|
|
...rawRequests[index],
|
|
...request,
|
|
duration: ts - start,
|
|
};
|
|
}
|
|
|
|
const trackOnBefore = (
|
|
details: WebRequest.OnBeforeRequestDetailsType & { reqBody: string },
|
|
) => {
|
|
if (details.method === "POST" && details.requestBody) {
|
|
const requestBody = details.requestBody;
|
|
if (requestBody.formData) {
|
|
details.reqBody = JSON.stringify(requestBody.formData);
|
|
} else if (requestBody.raw) {
|
|
const raw = requestBody.raw[0]?.bytes;
|
|
if (raw) {
|
|
details.reqBody = new TextDecoder("utf-8").decode(raw);
|
|
}
|
|
}
|
|
}
|
|
rawRequests.push({ ...details, startTs: Date.now(), duration: 0 });
|
|
};
|
|
const trackOnCompleted = (details: WebRequest.OnCompletedDetailsType) => {
|
|
modifyOnSpot(details);
|
|
};
|
|
const trackOnHeaders = (
|
|
details: WebRequest.OnBeforeSendHeadersDetailsType,
|
|
) => {
|
|
modifyOnSpot(details);
|
|
};
|
|
const trackOnError = (details: WebRequest.OnErrorOccurredDetailsType) => {
|
|
modifyOnSpot(details);
|
|
};
|
|
function startTrackingNetwork() {
|
|
rawRequests.length = 0;
|
|
browser.webRequest.onBeforeRequest.addListener(
|
|
// @ts-ignore
|
|
trackOnBefore,
|
|
{ urls: ["<all_urls>"] },
|
|
["requestBody"],
|
|
);
|
|
browser.webRequest.onBeforeSendHeaders.addListener(
|
|
trackOnHeaders,
|
|
{ urls: ["<all_urls>"] },
|
|
["requestHeaders"],
|
|
);
|
|
browser.webRequest.onCompleted.addListener(
|
|
trackOnCompleted,
|
|
{
|
|
urls: ["<all_urls>"],
|
|
},
|
|
["responseHeaders"],
|
|
);
|
|
browser.webRequest.onErrorOccurred.addListener(
|
|
trackOnError,
|
|
{
|
|
urls: ["<all_urls>"],
|
|
},
|
|
["extraHeaders"],
|
|
);
|
|
}
|
|
|
|
function stopTrackingNetwork() {
|
|
browser.webRequest.onBeforeRequest.removeListener(trackOnBefore);
|
|
browser.webRequest.onCompleted.removeListener(trackOnCompleted);
|
|
browser.webRequest.onErrorOccurred.removeListener(trackOnError);
|
|
}
|
|
|
|
async function initializeOffscreenDocument() {
|
|
const existingContexts = await browser.runtime.getContexts({});
|
|
let recording = false;
|
|
|
|
const offscreenDocument = existingContexts.find(
|
|
(c: { contextType: string }) => c.contextType === "OFFSCREEN_DOCUMENT",
|
|
);
|
|
|
|
if (!offscreenDocument) {
|
|
await browser.offscreen.createDocument({
|
|
url: "offscreen.html",
|
|
reasons: ["DISPLAY_MEDIA", "USER_MEDIA", "BLOBS"],
|
|
justification: "Recording from chrome.tabCapture API",
|
|
});
|
|
} else {
|
|
recording = offscreenDocument.documentUrl.endsWith("#recording");
|
|
}
|
|
return recording;
|
|
}
|
|
async function sendToActiveTab(message: {
|
|
type: string;
|
|
data?: any;
|
|
activeTabId?: number;
|
|
[key: string]: any;
|
|
}) {
|
|
let activeTabs = await browser.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
});
|
|
if (!activeTabs.length) {
|
|
activeTabs = await browser.tabs.query({});
|
|
}
|
|
let activeTab = activeTabs[0];
|
|
const sendTo = message.activeTabId || activeTab.id!;
|
|
if (!contentArmy[sendTo]) {
|
|
let tries = 0;
|
|
const exist = await new Promise((res) => {
|
|
const interval = setInterval(() => {
|
|
if (contentArmy[sendTo] || tries < 500) {
|
|
clearInterval(interval);
|
|
res(tries < 500);
|
|
}
|
|
}, 100);
|
|
});
|
|
if (!exist) throw new Error("Can't find required tab");
|
|
}
|
|
try {
|
|
void browser.tabs.sendMessage(sendTo, message);
|
|
} catch (e) {
|
|
console.error("Sending to active tab", e, message);
|
|
}
|
|
}
|
|
|
|
async function sendToOffscreen(message: {
|
|
type: string;
|
|
target: "offscreen";
|
|
data?: any;
|
|
[key: string]: any;
|
|
}) {
|
|
try {
|
|
return await browser.runtime.sendMessage(message);
|
|
} catch (e) {
|
|
console.error("Sending to offscreen", e);
|
|
}
|
|
}
|
|
|
|
function base64ToBlob(base64: string, mimeType = "video/webm") {
|
|
const binaryString = atob(base64.split(",")[1]);
|
|
const byteNumbers = new Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
byteNumbers[i] = binaryString.charCodeAt(i);
|
|
}
|
|
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
return new Blob([byteArray], { type: mimeType });
|
|
}
|
|
|
|
async function startRecording(
|
|
area: "tab" | "desktop",
|
|
microphone: boolean,
|
|
audioId: string,
|
|
slackChannels: { name: string; webhookId: number }[],
|
|
activeTabId: number | undefined,
|
|
withNetwork: boolean,
|
|
withConsole: boolean,
|
|
getRecState: () => string,
|
|
setOnStop?: (hook: any) => void,
|
|
) {
|
|
let activeTabs = await browser.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
});
|
|
if (!activeTabs.length) {
|
|
activeTabs = await browser.tabs.query({});
|
|
}
|
|
let activeTab = activeTabs[0];
|
|
const usedTab = activeTabId ?? activeTab.id;
|
|
try {
|
|
const streamId = await browser.tabCapture.getMediaStreamId({
|
|
targetTabId: usedTab,
|
|
});
|
|
const resp = await sendToOffscreen({
|
|
type: messages.offscreen.to.startRecording,
|
|
target: "offscreen",
|
|
data: streamId,
|
|
area,
|
|
microphone,
|
|
audioId,
|
|
});
|
|
if (!resp.success) {
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
};
|
|
void sendToActiveTab({
|
|
type: messages.content.to.unmount,
|
|
});
|
|
void sendToActiveTab({
|
|
type: messages.content.to.notification,
|
|
message: "Error starting recording",
|
|
});
|
|
return;
|
|
}
|
|
const mountMsg = {
|
|
type: "content:start",
|
|
microphone,
|
|
time: 0,
|
|
slackChannels,
|
|
activeTabId,
|
|
withConsole,
|
|
state: "recording",
|
|
// by default this is already handled by :start event
|
|
// that triggers mount with countdown
|
|
shouldMount: false,
|
|
};
|
|
void sendToActiveTab(mountMsg);
|
|
if (withNetwork) {
|
|
startTrackingNetwork();
|
|
}
|
|
|
|
let previousTab: number | null = usedTab ?? null;
|
|
function tabActivatedListener({ tabId }: { tabId: number }) {
|
|
const state = getRecState();
|
|
if (state === REC_STATE.stopped) {
|
|
stopTabActivationListening();
|
|
}
|
|
if (tabId !== previousTab) {
|
|
browser.runtime
|
|
.sendMessage({
|
|
type: messages.offscreen.to.checkRecStatus,
|
|
target: "offscreen",
|
|
})
|
|
.then((r) => {
|
|
const msg = {
|
|
...mountMsg,
|
|
...r,
|
|
shouldMount: true,
|
|
state: getRecState(),
|
|
activeTabId: null,
|
|
};
|
|
void sendToActiveTab(msg);
|
|
});
|
|
if (previousTab) {
|
|
const unmountMsg = { type: messages.content.to.unmount };
|
|
void browser.tabs.sendMessage(previousTab, unmountMsg);
|
|
}
|
|
previousTab = tabId;
|
|
}
|
|
}
|
|
function startTabActivationListening() {
|
|
browser.tabs.onActivated.addListener(tabActivatedListener);
|
|
}
|
|
function stopTabActivationListening() {
|
|
browser.tabs.onActivated.removeListener(tabActivatedListener);
|
|
}
|
|
|
|
const trackedTab: number | null = usedTab ?? null;
|
|
function tabUpdateListener(tabId: number, changeInfo: any) {
|
|
const state = getRecState();
|
|
if (state === REC_STATE.stopped) {
|
|
return stopTabListening();
|
|
}
|
|
|
|
if (changeInfo.status !== "complete") {
|
|
return (contentArmy[tabId] = false);
|
|
}
|
|
|
|
if (area === "tab" && (!trackedTab || tabId !== trackedTab)) {
|
|
return;
|
|
}
|
|
|
|
browser.runtime
|
|
.sendMessage({
|
|
type: messages.offscreen.to.checkRecStatus,
|
|
target: "offscreen",
|
|
})
|
|
.then((r) => {
|
|
const msg = {
|
|
...mountMsg,
|
|
...r,
|
|
shouldMount: true,
|
|
state,
|
|
activeTabId: area === "desktop" ? null : activeTabId,
|
|
};
|
|
void sendToActiveTab(msg);
|
|
});
|
|
}
|
|
|
|
function tabRemovedListener(tabId: number) {
|
|
if (tabId === trackedTab) {
|
|
void browser.runtime.sendMessage({
|
|
type: "offscr:stop-discard",
|
|
target: "offscreen",
|
|
});
|
|
finalVideoBase64 = "";
|
|
finalReady = false;
|
|
finalSpotObj = defaultSpotObj;
|
|
recordingState = {
|
|
activeTabId: null,
|
|
area: null,
|
|
recording: REC_STATE.stopped,
|
|
};
|
|
}
|
|
}
|
|
|
|
function startRemovedListening() {
|
|
browser.tabs.onRemoved.addListener(tabRemovedListener);
|
|
}
|
|
|
|
function stopRemovedListening() {
|
|
browser.tabs.onRemoved.removeListener(tabRemovedListener);
|
|
}
|
|
|
|
function startTabListening() {
|
|
browser.tabs.onUpdated.addListener(tabUpdateListener);
|
|
}
|
|
|
|
function stopTabListening() {
|
|
browser.tabs.onUpdated.removeListener(tabUpdateListener);
|
|
}
|
|
|
|
startTabListening();
|
|
if (area === "desktop") {
|
|
startTabActivationListening();
|
|
}
|
|
if (area === "tab") {
|
|
startRemovedListening();
|
|
}
|
|
setOnStop?.(() => {
|
|
stopTabListening();
|
|
if (area === "desktop") {
|
|
stopTabActivationListening();
|
|
}
|
|
if (area === "tab") {
|
|
stopRemovedListening();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error("Error starting recording", e, activeTab, activeTabId);
|
|
}
|
|
}
|
|
|
|
const decodeJwt = (jwt: string): any => {
|
|
const base64Url = jwt.split(".")[1];
|
|
if (!base64Url) {
|
|
return { exp: 0 };
|
|
}
|
|
const base64 = base64Url.replace("-", "+").replace("_", "/");
|
|
return JSON.parse(atob(base64));
|
|
};
|
|
|
|
const isTokenExpired = (token: string): boolean => {
|
|
const decoded: any = decodeJwt(token);
|
|
const currentTime = Date.now() / 1000;
|
|
return decoded.exp < currentTime;
|
|
};
|
|
|
|
function getRequestStatus(request: any): number {
|
|
if (request.statusCode) {
|
|
return request.statusCode;
|
|
}
|
|
if (request.error) {
|
|
return 0;
|
|
}
|
|
return 200;
|
|
}
|
|
});
|