openreplay/spot/entrypoints/background.ts
2024-10-25 15:35:30 +02:00

1236 lines
36 KiB
TypeScript

import { isTokenExpired } from "~/utils/jwt";
let checkBusy = false;
export default defineBackground(() => {
const CHECK_INT = 60 * 1000;
const PING_INT = 30 * 1000;
const VER = "1.0.10";
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",
toStop: "ort:stop",
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",
bumpNetwork: "ort:bump-network",
},
},
offscreen: {
to: {
checkRecStatus: "offscr:check-status",
startRecording: "offscr:start-recording",
stopRecording: "offscr:stop-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 defaultSettings = {
openInNewTab: true,
consoleLogs: true,
networkLogs: true,
ingestPoint: "https://app.openreplay.com",
};
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 = defaultSettings;
let recordingState = {
activeTabId: null,
area: null,
recording: REC_STATE.stopped,
audioPerm: 0,
} as Record<string, any>;
let jwtToken = "";
let refreshInt: any;
let pingInt: any;
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,
});
if (!refreshInt) {
refreshInt = setInterval(() => {
void refreshToken();
}, CHECK_INT);
}
if (!pingInt) {
pingInt = setInterval(() => {
void pingJWT();
}, PING_INT);
}
} else {
void browser.storage.local.remove("jwtToken");
void browser.runtime.sendMessage({
type: messages.popup.to.noLogin,
});
if (refreshInt) {
clearInterval(refreshInt);
}
if (pingInt) {
clearInterval(pingInt);
}
}
}
function safeApiUrl(url: string) {
let str = url;
if (str.endsWith("/")) {
str = str.slice(0, -1);
}
if (str.includes("app.openreplay.com")) {
str = str.replace("app.openreplay.com", "api.openreplay.com");
}
return str;
}
let slackChannels: { name: string; webhookId: number }[] = [];
void checkTokenValidity();
browser.storage.local.get("settings").then(async (data: any) => {
if (!data.settings) {
void browser.storage.local.set({ settings });
return;
}
settings = Object.assign(settings, data.settings);
});
async function refreshToken() {
const data = await browser.storage.local.get(["jwtToken", "settings"]);
if (!data.settings) {
await browser.storage.local.set({ defaultSettings });
data.settings = defaultSettings;
}
if (!data.jwtToken) {
setJWTToken("");
}
const { jwtToken, settings } = data;
const refreshUrl = `${safeApiUrl(settings.ingestPoint)}/api/spot/refresh`;
if (!isTokenExpired(jwtToken) || !jwtToken) {
if (refreshInt) {
clearInterval(refreshInt);
}
return true;
}
const resp = await fetch(refreshUrl, {
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,
}));
}
};
async function pingJWT(): Promise<void> {
const data = await browser.storage.local.get(["jwtToken", "settings"]);
if (!data.settings) {
await browser.storage.local.set({ defaultSettings });
data.settings = defaultSettings;
}
if (!data.jwtToken) {
setJWTToken("");
}
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}`,
"Ext-Version": VER,
},
});
if (!r.ok) {
void refreshToken();
}
} catch (e) {
void refreshToken();
}
}
let lastReq: Record<string, any> | null = null;
async function checkTokenValidity() {
if (checkBusy) return;
checkBusy = true;
const data = await browser.storage.local.get("jwtToken");
if (!data.jwtToken) {
void browser.runtime.sendMessage({
type: messages.popup.to.noLogin,
});
checkBusy = false;
return;
}
const ok = await refreshToken();
if (ok) {
setJWTToken(data.jwtToken);
if (!refreshInt) {
refreshInt = setInterval(() => {
void refreshToken();
}, CHECK_INT);
}
if (!pingInt) {
pingInt = setInterval(() => {
void pingJWT();
}, PING_INT);
}
}
checkBusy = false;
}
// @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.audioId,
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,
(hook) => {
onStop = hook;
},
);
// @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.injected.from.bumpNetwork) {
finalSpotObj.network.push(request.event);
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 === messages.content.from.toStop) {
if (recordingState.recording === REC_STATE.stopped) {
return console.error('Calling stopped recording?')
}
browser.runtime
.sendMessage({
type: messages.offscreen.to.stopRecording,
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) {
Object.assign(finalSpotObj, request.spot);
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: duration,
crop: finalSpotObj.crop,
vitals: finalSpotObj.vitals,
};
const videoData = finalVideoBase64;
getPlatformData().then(async ({ 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 data = await browser.storage.local.get("settings");
if (!data.settings) {
return void sendToActiveTab({
type: messages.content.to.notification,
message: "Error saving Spot: can't retrieve spot ingest",
});
}
const ingestUrl = safeApiUrl(data.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}`,
"Ext-Version": VER,
},
})
.then((r) => {
if (r.ok) {
return r.json();
} else {
if (r.status === 401) {
setJWTToken("");
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 create Spot",
});
}
const { id, mobURL, videoURL } = resp;
const link = settings.ingestPoint.includes(
"api.openreplay.com",
)
? "https://app.openreplay.com"
: settings.ingestPoint;
void sendToActiveTab({
type: "content:spot-saved",
url: `${link}/view-spot/${id}`,
});
setTimeout(() => {
void browser.tabs.create({
url: `${link}/view-spot/${id}`,
active: settings.openInNewTab,
});
}, 250);
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") {
await browser.tabs.create({
url: "https://www.openreplay.com/spot/welcome?ref=extension",
active: true,
});
}
// in future:
// const tabs = await browser.tabs.query({}) as chrome.tabs.Tab[]
// for (const tab of tabs) {
// if (tab.id) {
// this will require more permissions, do we even want this?
// void chrome.tabs.executeScript(tab.id, {file: "content"});
// }
// }
await checkTokenValidity();
await initializeOffscreenDocument();
});
void initializeOffscreenDocument();
async function initializeOffscreenDocument() {
const existingContexts = await browser.runtime.getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
});
const offscreenDocument = existingContexts.find(
(c: { contextType: string }) => c.contextType === "OFFSCREEN_DOCUMENT",
);
if (offscreenDocument) {
return;
// TODO: check manifestv3 for reloading context
// try {
// await browser.offscreen.closeDocument();
// } catch (e) {
// console.trace(e)
// }
}
try {
await browser.offscreen.createDocument({
url: "offscreen.html",
reasons: ["DISPLAY_MEDIA", "USER_MEDIA", "BLOBS"],
justification: "Recording from chrome.tabCapture API",
});
} catch (e) {
console.error("cant create new offscreen document", e);
}
return;
}
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!;
let attempts = 0;
// 10 seconds;
while (!contentArmy[sendTo] && attempts < 100) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (contentArmy[sendTo]) {
await browser.tabs.sendMessage(sendTo, message);
} else {
console.trace(
"Content script might not be ready in tab",
sendTo,
contentArmy,
message,
);
await browser.tabs.sendMessage(sendTo, 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,
withNetwork,
state: "recording",
// by default this is already handled by :start event
// that triggers mount with countdown
shouldMount: false,
};
void sendToActiveTab(mountMsg);
let previousTab: number | null = usedTab ?? null;
/** moves ui to active tab when screen recording */
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;
}
}
/** moves ui to active tab when screen recording */
function startTabActivationListening() {
browser.tabs.onActivated.addListener(tabActivatedListener);
}
/** moves ui to active tab when screen recording */
function stopTabActivationListening() {
browser.tabs.onActivated.removeListener(tabActivatedListener);
}
const trackedTab: number | null = usedTab ?? null;
/** reloads ui on currently active tab once its reloads itself */
function tabNavigatedListener(details: { tabId: number }) {
const state = getRecState();
if (state === REC_STATE.stopped) {
return stopNavListening();
}
contentArmy[details.tabId] = false
if (area === "tab" && (!trackedTab || details.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 startNavListening() {
browser.webNavigation.onCompleted.addListener(tabNavigatedListener)
}
function stopNavListening() {
browser.webNavigation.onCompleted.removeListener(tabNavigatedListener)
}
/** discards recording if was recording single tab and its now closed */
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);
}
startNavListening();
if (area === "desktop") {
// if desktop, watch for tab change events
startTabActivationListening();
}
if (area === "tab") {
// if tab, watch for tab remove changes to discard recording
startRemovedListening();
}
setOnStop(() => {
stopNavListening();
if (area === "desktop") {
stopTabActivationListening();
}
if (area === "tab") {
stopRemovedListening();
}
});
} catch (e) {
console.error("Error starting recording", e, activeTab, activeTabId);
}
}
});