import browser from "webextension-polyfill";
import type { Runtime } from "webextension-polyfill";
import type { ReportDetail } from "@ltt/shared";
import { submitNoteReport, submitRecordingReport } from "./api";
import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings";
import { buildGif, buildCursorGif, type VideoFrame } from "./gif";
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;
/** sampled cursor positions: {at, x, y} with client timestamps */
moveTrack: { at: number; x: number; y: number }[];
/** screencast frames captured while recording (bounded, decimated on overflow) */
video: VideoFrame[];
/** false once the debugger detaches (DevTools opened, tab navigated away) */
videoActive: boolean;
lastVideoAt: number;
consoleCount: number;
}
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,
moveTrack: [],
video: [],
videoActive: false,
lastVideoAt: 0,
consoleCount: 0,
};
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.moveTrack = [];
buffer.video = [];
buffer.videoActive = false;
buffer.lastVideoAt = 0;
buffer.consoleCount = 0;
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
// continuous screen video (Chrome only; falls back to per-event captures)
buffer.videoActive = await startScreenVideo(tabId);
return recorderStateOf(buffer);
}
function recorderStateOf(buffer: RecorderBuffer): RecorderState {
return {
recording: buffer.recording,
stepCount: buffer.events.length,
startedAt: buffer.startedAt,
};
}
/** Builds the persisted mouse track: offsets from recording start, capped. */
function buildMouseTrack(buffer: RecorderBuffer): { viewport: { w: number; h: number }; points: { t: number; x: number; y: number }[] } | null {
if (!buffer.moveTrack.length || !buffer.startedAt) return null;
const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null;
if (!viewport?.w || !viewport?.h) return null;
let points = buffer.moveTrack.map((p) => ({
t: Math.max(p.at - buffer.startedAt!, 0),
x: p.x,
y: p.y,
}));
// decimate very long tracks instead of dropping them
while (points.length > 5000) points = points.filter((_, i) => i % 2 === 0);
return { viewport: { w: viewport.w, h: viewport.h }, points };
}
async function recorderStop(tabId: number): Promise<RecorderState> {
const buffer = recorder(tabId);
ensureRecordingFreshness(buffer);
if (!buffer.recording) return recorderStateOf(buffer);
// the screencast must stop before anything else can race its frames
void stopScreenVideo(tabId);
buffer.videoActive = false;
// collect the tail of the cursor track before anything else can race it
const flushed = (await browser.tabs.sendMessage(tabId, { type: "recorder_flush_moves" }).catch(() => null)) as
| { points?: { at: number; x: number; y: number }[] }
| null;
if (flushed?.points?.length) buffer.moveTrack.push(...flushed.points);
const settings = await getSettings();
const gifFileId = await uploadTrackGif(buffer);
const steps = buffer.events.map((event) => ({
type: event.type,
offset_ms: buffer.startedAt ? Math.max(event.at - buffer.startedAt, 0) : 0,
data: event.data,
attachment_id: 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: gifFileId ? [gifFileId] : [],
mouse_track: buildMouseTrack(buffer),
},
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();
buffer.moveTrack = [];
buffer.video = [];
return recorderStateOf(buffer);
}
/**
* Uploads the recorded screen as an animated GIF. Prefers the continuous
* screencast frames (cropped to a square following the cursor); when the
* video isn't available (Firefox, debugger busy, setting off) falls back to
* the per-event screenshots. Returns the uploaded file id.
*/
async function uploadTrackGif(buffer: RecorderBuffer): Promise<string | null> {
try {
if (buffer.video.length) {
const viewport = (buffer.environment?.viewport as { w?: number; h?: number } | undefined) ?? null;
const track = buildMouseTrack(buffer)?.points ?? [];
const blob = await buildCursorGif(buffer.video, {
size: 512,
viewport: viewport?.w && viewport?.h ? { w: viewport.w, h: viewport.h } : { w: 1280, h: 720 },
startedAt: buffer.startedAt ?? Date.now(),
track,
});
if (blob) return await uploadGifBlob(blob);
debugLog("cursor gif failed: no frames encoded");
}
} catch (error) {
debugLog(`cursor gif failed: ${error instanceof Error ? error.message : String(error)}`);
}
const indices = [...buffer.screenshots.keys()].sort((a, b) => a - b);
const frames = indices
.filter((index) => buffer.screenshots.has(index))
.map((index, position, list) => {
const at = buffer.events[index]?.at ?? Date.now();
const nextAt = position + 1 < list.length ? buffer.events[list[position + 1]]?.at : undefined;
// pace the GIF like the track; degenerate gaps get sane bounds
const delayMs = nextAt != null ? Math.min(Math.max(nextAt - at, 400), 2000) : 1500;
return { dataUrl: buffer.screenshots.get(index)!, delayMs };
});
if (!frames.length) return null;
try {
const blob = await buildGif(frames);
if (!blob) return null;
return await uploadGifBlob(blob);
} catch (error) {
debugLog(`track gif failed: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
async function uploadGifBlob(blob: Blob): Promise<string | null> {
const client = await getHttpClient();
const file = new File([blob], "recording.gif", { type: "image/gif" });
const uploaded = await client.upload<{ file_id: string }>("/api/uploads", file);
debugLog(`track gif uploaded: ${uploaded.file_id}`);
return uploaded.file_id;
}
// ---------- 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);
}
// ---------- replay (extension users can reproduce a recorded track) ----------
/** Minimal shape of the chrome.debugger API (absent in Firefox). */
interface DebuggerApiLike {
attach(target: { tabId: number }, version: string): Promise<void>;
detach(target: { tabId: number }): Promise<void>;
sendCommand(target: { tabId: number }, method: string, params?: Record<string, unknown>): Promise<unknown>;
onEvent: {
addListener(callback: (source: { tabId: number }, method: string, params?: Record<string, unknown>) => void): void;
};
onDetach: {
addListener(callback: (source: { tabId: number }) => void): void;
};
}
function debuggerApi(): DebuggerApiLike | null {
return (browser as unknown as { debugger?: DebuggerApiLike }).debugger ?? null;
}
// tabs with the debugger attached for CSS :hover replay during a running replay
const hoverDebuggerTabs = new Set<number>();
/**
* Attaches the Chrome debugger to the replay tab so the virtual cursor's
* positions can be replayed as *trusted* input (Input.dispatchMouseEvent) —
* only trusted input applies CSS :hover, synthetic DOM events can't.
* Chrome shows its "started debugging" infobar; the user can turn this off
* via the hoverReplay setting. Best effort: a busy debugger (DevTools open)
* just disables CSS hover for the replay.
*/
async function attachHoverDebugger(tabId: number): Promise<void> {
const settings = await getSettings();
if (!settings.hoverReplay) return;
const api = debuggerApi();
if (!api) return; // Firefox has no debugger API — JS-level hover still works
try {
await api.attach({ tabId }, "1.3");
hoverDebuggerTabs.add(tabId);
debugLog("hover debugger attached");
} catch (error) {
debugLog(`hover debugger attach failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ---------- screen video (CDP screencast while recording) ----------
/** tabs with a recording screencast attached */
const screencastTabs = new Set<number>();
/** ~15 fps of 512px squares; bounded memory, decimated when overflowing */
const VIDEO_MIN_INTERVAL_MS = 66;
const MAX_VIDEO_FRAMES = 600;
/** screencast JPEGs are downscaled by CDP; the crop needs some headroom */
const VIDEO_MAX_DIMENSION = 800;
/**
* Starts a CDP screencast on the tab so the recording gets continuous frames
* (captureVisibleTab is quota-limited to ~2/sec — nowhere near video). Chrome
* shows its "started debugging" infobar; the user can turn this off via the
* videoCapture setting. Best effort: a busy debugger just disables the video.
*/
async function startScreenVideo(tabId: number): Promise<boolean> {
const settings = await getSettings();
if (!settings.videoCapture) return false;
const api = debuggerApi();
if (!api) return false; // Firefox: per-event screenshots still feed the GIF
try {
await api.attach({ tabId }, "1.3");
await api.sendCommand({ tabId }, "Page.startScreencast", {
format: "jpeg",
quality: 55,
maxWidth: VIDEO_MAX_DIMENSION,
maxHeight: VIDEO_MAX_DIMENSION,
everyNthFrame: 1,
});
screencastTabs.add(tabId);
debugLog("recording screencast started");
return true;
} catch (error) {
debugLog(`recording screencast failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
async function stopScreenVideo(tabId: number): Promise<void> {
if (!screencastTabs.delete(tabId)) return;
const api = debuggerApi();
await api?.sendCommand({ tabId }, "Page.stopScreencast").catch(() => {});
await api?.detach({ tabId }).catch(() => {});
}
/** base64 (screencast wire format) → bytes, so frames take half the memory */
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/** Global debugger event listener: screencast frames land in the tab's buffer. */
function onDebuggerEvent(source: { tabId: number }, method: string, params?: Record<string, unknown>): void {
if (method !== "Page.screencastFrame") return;
const api = debuggerApi();
const sessionId = (params?.sessionId as number | undefined) ?? null;
// every frame must be acked or Chrome throttles the screencast to a crawl
if (api && sessionId != null) void api.sendCommand({ tabId: source.tabId }, "Page.screencastFrameAck", { sessionId }).catch(() => {});
const buffer = recorders.get(source.tabId);
if (!buffer?.recording || !buffer.videoActive) return;
const data = params?.data as string | undefined;
const meta = params?.metadata as { deviceWidth?: number; deviceHeight?: number } | undefined;
if (!data || !meta?.deviceWidth || !meta.deviceHeight) return;
const now = Date.now();
if (now - buffer.lastVideoAt < VIDEO_MIN_INTERVAL_MS) return; // hold ~15 fps
buffer.lastVideoAt = now;
buffer.video.push({ at: now, jpeg: base64ToBytes(data), w: meta.deviceWidth, h: meta.deviceHeight });
// decimate in place instead of growing without bound
while (buffer.video.length > MAX_VIDEO_FRAMES) buffer.video = buffer.video.filter((_, i) => i % 2 === 0);
}
/** The debugger left (DevTools opened, user clicked "cancel") — no more frames. */
function onDebuggerDetach(source: { tabId: number }): void {
screencastTabs.delete(source.tabId);
const buffer = recorders.get(source.tabId);
if (buffer?.videoActive) {
buffer.videoActive = false;
debugLog(`screencast detached from tab ${source.tabId}`);
}
}
const debugApi = debuggerApi();
debugApi?.onEvent.addListener(onDebuggerEvent);
debugApi?.onDetach.addListener(onDebuggerDetach);
async function detachHoverDebugger(tabId: number): Promise<void> {
if (!hoverDebuggerTabs.delete(tabId)) return;
await debuggerApi()?.detach({ tabId }).catch(() => {});
}
function dispatchHoverMove(tabId: number, x: number, y: number): void {
void debuggerApi()
?.sendCommand({ tabId }, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: Math.round(x),
y: Math.round(y),
button: "none",
buttons: 0,
pointerType: "mouse",
})
.catch(() => {});
}
function waitTabComplete(tabId: number, timeoutMs = 20000): Promise<boolean> {
return new Promise((resolve) => {
const finish = (completed: boolean) => {
clearTimeout(timer);
browser.tabs.onUpdated.removeListener(listener);
browser.tabs.onRemoved.removeListener(removed);
resolve(completed);
};
const timer = setTimeout(() => finish(false), timeoutMs);
const listener = (id: number, changeInfo: { status?: string }) => {
if (id === tabId && changeInfo.status === "complete") finish(true);
};
const removed = (id: number) => {
if (id === tabId) finish(false);
};
browser.tabs.onUpdated.addListener(listener);
browser.tabs.onRemoved.addListener(removed);
});
}
interface ReplayFailure {
index: number;
type: string;
reason: string;
}
export interface ReplayResult {
played: number;
total: number;
failures: ReplayFailure[];
}
/**
* Replays a recorded track in a fresh tab: opens the recorded page, then
* performs the steps with their recorded pacing (gaps capped so stale
* timings can't stall the replay) while a virtual cursor walks the recorded
* mouse path. Page interactions are executed by the content script of the
* replayed tab. Fails loudly when the page can't be loaded or the tab dies,
* and collects per-step failures (e.g. elements that no longer exist).
*/
async function replayReport(token: string): Promise<ReplayResult> {
const client = await getHttpClient();
const report = await client.get<ReportDetail>(`/api/reports/${token}`);
if (report.type !== "recording") throw new Error("Only recordings can be replayed");
if (!report.page_url) throw new Error("Recording has no start page URL");
const tab = await browser.tabs.create({ url: report.page_url, active: true });
const tabId = tab.id;
if (tabId == null) throw new Error("Could not open replay tab");
if (!(await waitTabComplete(tabId))) {
await browser.tabs.remove(tabId).catch(() => {});
throw new Error(`Page did not load within 20 s: ${report.page_url}`);
}
// the replay tab is fresh — the content script may not be there yet
await ensureContentScript(tabId);
// a virtual cursor walks the recorded mouse path while the steps play
if (report.mouse_track?.points?.length) {
await browser.tabs.sendMessage(tabId, { type: "replay_start", track: report.mouse_track }).catch(() => {});
}
// trusted-input hover replay (CSS :hover) — must detach in every exit path
await attachHoverDebugger(tabId);
try {
return await runReplaySteps(tabId, report);
} finally {
await detachHoverDebugger(tabId);
}
}
/** Drives the recorded steps inside the replay tab; cursor already running. */
async function runReplaySteps(tabId: number, report: ReportDetail): Promise<ReplayResult> {
const steps = report.steps ?? [];
const startedAt = Date.now();
const failures: ReplayFailure[] = [];
let played = 0;
for (const step of steps) {
const stepIndex = step.step_index ?? played;
// reproduce the recorded pacing, but cap each gap so stale timings don't stall
const wait = Math.min(Math.max(step.offset_ms - (Date.now() - startedAt), 0), 2000);
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
// the tab may have been closed mid-replay (by the user or the page itself)
const alive = await browser.tabs.get(tabId).then(
() => true,
() => false
);
if (!alive) throw new Error("Replay tab was closed before the track finished");
if (step.type === "url_change" && typeof step.data?.to_url === "string") {
await browser.tabs.update(tabId, { url: step.data.to_url });
if (!(await waitTabComplete(tabId))) {
throw new Error(`Page did not load within 20 s: ${step.data.to_url}`);
}
// the navigation destroyed the page's content script and cursor —
// reinstall both, resuming the cursor path from the current step
await ensureContentScript(tabId);
if (report.mouse_track?.points?.length) {
await browser.tabs
.sendMessage(tabId, { type: "replay_start", track: report.mouse_track, from_t: step.offset_ms })
.catch(() => {});
}
played++;
} else if (step.type === "click" || step.type === "input") {
const response = (await browser.tabs.sendMessage(tabId, { type: "replay_step", data: step.data }).catch(() => null)) as
| { ok: boolean }
| null;
if (response?.ok) {
played++;
} else {
failures.push({ index: stepIndex, type: step.type, reason: "Element not found on the current page" });
}
} else {
// note/screenshot/console steps have no page effect — still count as played
played++;
}
// keep the cursor clock aligned with the (gap-capped) step clock so the
// virtual mouse doesn't drift behind the actions as the replay proceeds
await browser.tabs.sendMessage(tabId, { type: "replay_sync", recorded_ms: step.offset_ms }).catch(() => {});
}
await new Promise((resolve) => setTimeout(resolve, 500));
await browser.tabs.sendMessage(tabId, { type: "replay_stop" }).catch(() => {});
const summary = failures.length
? `Replay finished: ${played}/${steps.length} steps (${failures.length} failed)`
: `Replay finished: ${played}/${steps.length} steps`;
await browser.tabs.sendMessage(tabId, { type: "show_flash", text: summary }).catch(() => {});
return { played, total: steps.length, failures };
}
/** 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);
void stopScreenVideo(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; the report's
// page_url stays the URL where recording *started* (replay starts there)
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
const buffer = recorders.get(tabId);
if (!buffer?.recording) return;
if (changeInfo.url && buffer.lastUrl !== changeInfo.url && tab.url) {
if (!buffer.pageUrl) buffer.pageUrl = tab.url;
const eventIndex = buffer.events.push({
type: "url_change",
at: Date.now(),
data: { from_url: buffer.lastUrl, to_url: tab.url, trigger: "tab_updated" },
}) - 1;
buffer.lastUrl = tab.url;
if (!buffer.videoActive) void captureForEvent(tabId, eventIndex);
}
// a full navigation destroyed the page's capture listeners — reinstall them
// (and the recorder bar) once the new document is ready
if (changeInfo.status === "complete") {
await ensureContentScript(tabId);
await browser.tabs.sendMessage(tabId, { type: "recorder_state", recording: true }).catch(() => {});
}
});
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 "replay_report": {
try {
return { ok: true, data: await replayReport(msg.token as string) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
case "replay_hover_move": {
// virtual cursor position mirrored for trusted-input CSS :hover
if (tabId != null && hoverDebuggerTabs.has(tabId)) {
dispatchHoverMove(tabId, Number(msg.x), Number(msg.y));
}
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);
// a frame per event feeds the fallback GIF when the screencast is off
// (bounded to avoid runaway memory); the video path doesn't need them
if (!buffer.videoActive && buffer.events.length <= 60) {
await captureForEvent(tabId!, buffer.events.length - 1);
}
return { ok: true, data: { stepCount: buffer.events.length } };
}
case "recorder_console": {
// console dump relayed from the MAIN-world tap — becomes timeline steps
const buffer = recorder(tabId!);
if (!buffer.recording) return { ok: true, data: { recording: false } };
const settings = await getSettings();
if (!settings.consoleCapture) return { ok: true, data: { recording: false } };
if (buffer.consoleCount >= 200) return { ok: true };
buffer.consoleCount++;
buffer.events.push({
type: "console",
at: typeof msg.at === "number" ? msg.at : Date.now(),
data: {
level: msg.level === "warning" ? "warning" : "error",
text: String(msg.text ?? "").slice(0, 2000),
},
});
return { ok: true };
}
case "recorder_environment": {
const buffer = recorder(tabId!);
buffer.environment = msg.environment as Record<string, unknown>;
return { ok: true };
}
case "recorder_move_batch": {
const buffer = recorder(tabId!);
if (!buffer.recording) return { ok: true, data: { recording: false } };
const points = (msg.points ?? []) as { at: number; x: number; y: number }[];
if (buffer.moveTrack.length + points.length <= 20000) buffer.moveTrack.push(...points);
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}` };
}
});