Newer
Older
bugtrail / packages / extension / src / content / index.ts
/**
 * Content script bootstrap — deliberately minimal:
 * no UI, no network; mounts the shadow-DOM overlay, relays DOM events
 * and (while recording) captures clicks/inputs for the background buffer.
 */
import browser from "webextension-polyfill";
import type { ElementContext } from "@ltt/shared";
import { mountOverlay } from "./overlay/mount";
import { buildElementContext, collectEnvironment } from "../lib/selector";

let overlay: ReturnType<typeof mountOverlay> | null = null;

function getOverlay() {
  if (!overlay) overlay = mountOverlay();
  return overlay;
}

// ---------- recorder capture (event listeners, no UI) ----------

let captureListeners: (() => void) | null = null;

/** Longest input debounced per element; flushes a single input event per pause. */
const pendingInputs = new Map<Element, { timer: number; element: ElementContext }>();

function sendRecorderEvent(type: "click" | "input", element: ElementContext, data: Record<string, unknown>) {
  void browser.runtime
    .sendMessage({
      type: "recorder_event",
      event: { type, data: { element, ...data }, at: Date.now() },
    })
    .catch(() => {});
}

function flushInput(element: Element) {
  const pending = pendingInputs.get(element);
  if (!pending) return;
  pendingInputs.delete(element);
  window.clearTimeout(pending.timer);
  const input = element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
  const isPassword = (input as HTMLInputElement).type === "password";
  const value = isPassword ? null : input.value.slice(0, 500);
  sendRecorderEvent("input", pending.element, {
    value_length: input.value.length,
    value,
    input_type: (input as HTMLInputElement).type ?? null,
  });
}

function isOurOverlay(target: EventTarget | null): boolean {
  // events originating in the closed shadow root are retargeted to the host
  return target instanceof Element && target.id === "ltt-overlay-host";
}

function attachCaptureListeners() {
  if (captureListeners) return;

  const onClick = (event: MouseEvent) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (!(target instanceof Element)) return;
    sendRecorderEvent("click", buildElementContext(target), {
      button: event.button,
    });
  };

  const onInput = (event: Event) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (
      !(target instanceof HTMLInputElement) &&
      !(target instanceof HTMLTextAreaElement) &&
      !(target instanceof HTMLSelectElement)
    ) {
      return true;
    }
    const existing = pendingInputs.get(target);
    if (existing) window.clearTimeout(existing.timer);
    const timer = window.setTimeout(() => flushInput(target), 250);
    pendingInputs.set(target, { timer, element: buildElementContext(target) });
  };

  document.addEventListener("click", onClick, true);
  document.addEventListener("input", onInput, true);
  captureListeners = () => {
    document.removeEventListener("click", onClick, true);
    document.removeEventListener("input", onInput, true);
    for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer);
    pendingInputs.clear();
  };
}

function detachCaptureListeners() {
  if (!captureListeners) return;
  captureListeners();
  captureListeners = null;
}

// ---------- message wiring ----------

browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  const msg = message as { type: string; [key: string]: unknown };
  switch (msg.type) {
    case "ping":
      sendResponse({ ok: true, data: "pong" });
      return true;

    case "start_picker":
      getOverlay().startPicker();
      sendResponse({ ok: true });
      return true;

    case "recorder_state": {
      const recording = Boolean((message as { recording?: unknown }).recording);
      getOverlay().setRecorderState(recording);
      if (recording) {
        attachCaptureListeners();
        void browser.runtime
          .sendMessage({ type: "recorder_environment", environment: collectEnvironment() })
          .catch(() => {});
      } else {
        detachCaptureListeners();
      }
      sendResponse({ ok: true });
      return true;
    }

    default:
      return true;
  }
});