import browser from "webextension-polyfill";
import type { Runtime } from "webextension-polyfill";
import { submitNoteReport, submitRecordingReport } from "./api";
import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings";
import type {
BackgroundResponse,
RecordEvent,
RecorderState,
} from "../lib/messages";
const OVERLAY_ACTIVE = new Set<number>();
// debug ring buffer (inspected from the e2e harness / DevTools)
const DEBUG_LOG: string[] = [];
function debugLog(line: string) {
DEBUG_LOG.push(`${new Date().toISOString()} ${line}`);
if (DEBUG_LOG.length > 50) DEBUG_LOG.shift();
}
// ---------- recorder state (lives in the worker; survives page navigations) ----------
interface RecorderBuffer {
recording: boolean;
startedAt: number | null;
events: RecordEvent[];
pageUrl: string | null;
pageTitle: string | null;
environment: Record<string, unknown> | null;
/** screenshots captured for click events, keyed by event index */
screenshots: Map<number, string>;
lastUrl: string | null;
}
const recorders = new Map<number, RecorderBuffer>();
function recorder(tabId: number): RecorderBuffer {
let buffer = recorders.get(tabId);
if (!buffer) {
buffer = {
recording: false,
startedAt: null,
events: [],
pageUrl: null,
pageTitle: null,
environment: null,
screenshots: new Map(),
lastUrl: null,
};
recorders.set(tabId, buffer);
}
return buffer;
}
async function setUiHidden(tabId: number, hidden: boolean): Promise<void> {
// content script may be absent (chrome-internal pages) — ignore failures
await browser.tabs.sendMessage(tabId, { type: "set_ui_hidden", hidden }).catch(() => {});
}
async function captureVisibleTab(tabId: number): Promise<string> {
const tab = await browser.tabs.get(tabId);
// hide the plugin's own UI so it never lands in the screenshot
await setUiHidden(tabId, true);
try {
// let the compositor repaint the page without the overlay
await new Promise((resolve) => setTimeout(resolve, 150));
// the first argument of captureVisibleTab is a *window* id
return (await browser.tabs.captureVisibleTab(tab.windowId ?? undefined, { format: "png" })) as string;
} finally {
await setUiHidden(tabId, false);
}
}
async function captureForEvent(tabId: number, eventIndex: number): Promise<void> {
try {
const dataUrl = await captureVisibleTab(tabId);
recorder(tabId).screenshots.set(eventIndex, dataUrl);
debugLog(`event screenshot ${eventIndex} captured`);
} catch (error) {
// tab may be inactive or chrome-internal — skip the screenshot
debugLog(`event screenshot ${eventIndex} failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
function ensureRecordingFreshness(buffer: RecorderBuffer) {
// drop stale buffers older than 2 hours
if (buffer.startedAt && Date.now() - buffer.startedAt > 2 * 60 * 60 * 1000) {
buffer.recording = false;
buffer.events = [];
buffer.startedAt = null;
}
}
async function recorderStart(tabId: number): Promise<RecorderState> {
const buffer = recorder(tabId);
const tab = await browser.tabs.get(tabId);
buffer.recording = true;
buffer.startedAt = Date.now();
buffer.events = [];
buffer.screenshots = new Map();
buffer.pageUrl = tab.url ?? null;
buffer.pageTitle = tab.title ?? null;
buffer.lastUrl = tab.url ?? null;
buffer.environment = null; // content script supplies it with the first event
return recorderStateOf(buffer);
}
function recorderStateOf(buffer: RecorderBuffer): RecorderState {
return {
recording: buffer.recording,
stepCount: buffer.events.length,
startedAt: buffer.startedAt,
};
}
async function recorderStop(tabId: number): Promise<RecorderState> {
const buffer = recorder(tabId);
ensureRecordingFreshness(buffer);
if (!buffer.recording) return recorderStateOf(buffer);
const settings = await getSettings();
const attachmentIdByStep = await uploadStepScreenshots(buffer);
const steps = buffer.events.map((event, index) => ({
type: event.type,
offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0,
data: event.data,
attachment_id: attachmentIdByStep.get(index) ?? null,
}));
if (buffer.events.length > 0) {
await submitRecordingReport({
payload: {
type: "recording",
title: `Recording ${new Date().toLocaleString()} — ${buffer.pageTitle ?? "page"}`,
description: null,
page_url: buffer.pageUrl,
page_title: buffer.pageTitle,
environment: buffer.environment ?? {},
steps,
attachment_ids: [...attachmentIdByStep.values()],
},
settings,
}).catch(async (error) => {
// surface submit failure to the content script
buffer.recording = false;
throw error;
});
}
buffer.recording = false;
buffer.startedAt = null;
buffer.events = [];
buffer.screenshots = new Map();
return recorderStateOf(buffer);
}
/** Uploads captured step screenshots; returns attachment id by event index. */
async function uploadStepScreenshots(buffer: RecorderBuffer): Promise<Map<number, string>> {
const client = await getHttpClient();
// buffer.screenshots is keyed by the event index, which matches the step index
const attachmentIdByStep = new Map<number, string>();
for (const [eventIndex, dataUrl] of buffer.screenshots) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `step-${eventIndex + 1}.png`, { type: "image/png" });
const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file);
attachmentIdByStep.set(eventIndex, uploaded.file_id);
debugLog(`step screenshot ${eventIndex} uploaded: ${uploaded.file_id}`);
} catch (error) {
debugLog(`step screenshot ${eventIndex} failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
return attachmentIdByStep;
}
// ---------- content-script injection ----------
async function ensureContentScript(tabId: number): Promise<void> {
try {
await browser.tabs.sendMessage(tabId, { type: "ping" });
return;
} catch {
// not injected yet
}
await browser.scripting.executeScript({
target: { tabId },
files: ["content.js"],
});
}
async function startPicker(tabId: number): Promise<void> {
await ensureContentScript(tabId);
OVERLAY_ACTIVE.add(tabId);
await browser.tabs.sendMessage(tabId, { type: "start_picker" }).catch(async (error) => {
// the page may need a reload for the fresh script; retry once
await browser.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
await browser.tabs.sendMessage(tabId, { type: "start_picker" }).catch(() => {
throw error;
});
});
}
async function startRecorder(tabId: number): Promise<RecorderState> {
await ensureContentScript(tabId);
const state = await recorderStart(tabId);
await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {});
return state;
}
async function toggleRecorder(tabId: number): Promise<RecorderState> {
const buffer = recorder(tabId);
if (buffer.recording) {
const state = await recorderStop(tabId);
await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: false }).catch(() => {});
return state;
}
return startRecorder(tabId);
}
/** Latest report of the default project + its project share token (for the panel link). */
async function latestReport(): Promise<{
report: { title: string; type: string; status: string; created_at: string; share_token: string } | null;
project_token: string | null;
} | null> {
const settings = await getSettings();
if (!settings.token || !settings.defaultProjectId) return null;
const client = await getHttpClient();
const projects = await client.get<{ id: string; share_token: string }[]>("/api/projects");
const project = projects.find((p) => p.id === settings.defaultProjectId);
const list = await client.get<{ items: { title: string; type: string; status: string; created_at: string; share_token: string }[] }>(
`/api/projects/${settings.defaultProjectId}/reports?limit=1`
);
return { report: list.items[0] ?? null, project_token: project?.share_token ?? null };
}
// ---------- wiring ----------
browser.action.onClicked.addListener(async (tab) => {
if (tab.id == null) return;
try {
// first run: the toolbar click takes you to settings instead of the picker
const settings = await getSettings();
if (!settings.token || !settings.defaultProjectId) {
await browser.runtime.openOptionsPage();
return;
}
await startPicker(tab.id);
} catch (error) {
console.error("[ltt] failed to start picker:", error);
}
});
browser.commands?.onCommand.addListener(async (command) => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) return;
if (command === "start-picker") {
await startPicker(tab.id).catch(console.error);
} else if (command === "toggle-recorder") {
const buffer = recorder(tab.id);
if (buffer.recording) {
await recorderStop(tab.id).catch(console.error);
await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false }).catch(() => {});
} else {
await startRecorder(tab.id).catch(console.error);
}
}
});
browser.tabs.onRemoved.addListener((tabId) => {
OVERLAY_ACTIVE.delete(tabId);
recorders.delete(tabId);
});
// debug/test hook: lets the e2e harness (and DevTools) trigger the action
// and recorder-toggle handlers the way the toolbar button and shortcuts do
Object.assign(self as unknown as Record<string, unknown>, {
__lttDebug: () => DEBUG_LOG,
__lttTriggerAction: async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) return;
await startPicker(tab.id).catch(console.error);
},
__lttToggleRecorder: async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) return;
const buffer = recorder(tab.id);
if (buffer.recording) {
await recorderStop(tab.id).catch(console.error);
await browser.tabs.sendMessage(tab.id, { type: "recorder_state", recording: false }).catch(() => {});
} else {
await startRecorder(tab.id).catch(console.error);
}
},
});
// keep the recorder's page metadata fresh on SPA navigations
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
const buffer = recorders.get(tabId);
if (!buffer?.recording) return;
if (changeInfo.url) buffer.pageUrl = changeInfo.url;
if (changeInfo.title) buffer.pageTitle = changeInfo.title;
if (changeInfo.url && buffer.lastUrl !== changeInfo.url && tab.url) {
buffer.events.push({
type: "url_change",
at: Date.now(),
data: { from_url: buffer.lastUrl, to_url: tab.url, trigger: "tab_updated" },
});
buffer.lastUrl = tab.url;
}
});
browser.runtime.onMessage.addListener(async (message: unknown, sender: Runtime.MessageSender) => {
const msg = message as { type: string; [key: string]: unknown };
const tabId = sender.tab?.id;
switch (msg.type) {
case "ping":
return { ok: true, data: "pong" };
// popup actions: the popup has no tab context, resolve the active tab here
case "start_picker": {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) return { ok: false, error: "No active tab" };
await startPicker(tab.id).catch((error) => console.error("[ltt] failed to start picker:", error));
return { ok: true };
}
case "toggle_recorder": {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) return { ok: false, error: "No active tab" };
try {
const state = await toggleRecorder(tab.id);
return { ok: true, data: state };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "latest_report": {
try {
return { ok: true, data: await latestReport() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "delete_report": {
try {
const client = await getHttpClient();
await client.del(`/api/reports/${msg.token as string}`);
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "open_panel": {
const settings = await getSettings();
await browser.tabs.create({ url: panelBase(settings) });
return { ok: true };
}
case "capture": {
debugLog("capture requested");
try {
const dataUrl = await captureVisibleTab(tabId!);
debugLog(`capture ok (${dataUrl.length} bytes)`);
return { ok: true, data: { dataUrl } };
} catch (error) {
debugLog(`capture failed: ${String(error)}`);
return { ok: false, error: String(error) };
}
}
case "submit_note": {
debugLog("submit_note received");
try {
const result = await submitNoteReport(msg.payload as Parameters<typeof submitNoteReport>[0]);
debugLog(`submit_note ok: ${JSON.stringify(result)}`);
return { ok: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLog(`submit_note failed: ${message}`);
return { ok: false, error: message };
}
}
case "recorder_event": {
const buffer = recorder(tabId!);
if (!buffer.recording) return { ok: true, data: { recording: false } };
ensureRecordingFreshness(buffer);
const { event } = msg as unknown as { event: RecordEvent };
buffer.events.push(event);
// auto-screenshot on clicks (bounded to avoid runaway captures)
if (event.type === "click" && buffer.events.length <= 30) {
await captureForEvent(tabId!, buffer.events.length - 1);
}
return { ok: true, data: { stepCount: buffer.events.length } };
}
case "recorder_environment": {
const buffer = recorder(tabId!);
buffer.environment = msg.environment as Record<string, unknown>;
return { ok: true };
}
case "recorder_screenshot": {
// a manual screenshot becomes its own step attached to the recording
const buffer = recorder(tabId!);
if (!buffer.recording) return { ok: true, data: { recording: false } };
ensureRecordingFreshness(buffer);
buffer.events.push({
type: "screenshot",
at: Date.now(),
data: { trigger: "manual" },
});
await captureForEvent(tabId!, buffer.events.length - 1);
return { ok: true, data: { stepCount: buffer.events.length } };
}
case "open_options": {
await browser.runtime.openOptionsPage();
return { ok: true };
}
case "list_projects": {
try {
const client = await getHttpClient();
const projects = await client.get<{ id: string; name: string }[]>("/api/projects");
return { ok: true, data: projects };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "recorder_stop": {
try {
const state = await recorderStop(tabId!);
return { ok: true, data: state };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "recorder_state_request": {
const buffer = tabId != null ? recorders.get(tabId) : undefined;
return { ok: true, data: buffer ? recorderStateOf(buffer) : { recording: false, stepCount: 0, startedAt: null } };
}
case "settings_get": {
return { ok: true, data: await getSettings() };
}
case "settings_save": {
await saveSettings(msg.patch as Record<string, never>);
return { ok: true };
}
case "login": {
try {
const user = await login(msg.email as string, msg.password as string);
return { ok: true, data: user };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "logout": {
await logout();
return { ok: true };
}
default:
return { ok: false, error: `Unknown message type: ${msg.type}` };
}
});