Newer
Older
bugtrail / packages / extension / src / background / index.ts
import browser from "webextension-polyfill";
import type { Runtime } from "webextension-polyfill";
import { submitNoteReport, submitRecordingReport } from "./api";
import { getSettings, saveSettings, login, logout, getHttpClient } 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 captureVisibleTab(tabId: number): Promise<string> {
  // the first argument of captureVisibleTab is a *window* id
  const tab = await browser.tabs.get(tabId);
  return (await browser.tabs.captureVisibleTab(tab.windowId ?? undefined, { format: "png" })) as string;
}

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;
}

// ---------- 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" };

    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}` };
  }
});